1633 lines
53 KiB
C
1633 lines
53 KiB
C
/* SPDX-License-Identifier: GPL-3.0-only */
|
|
/* Authenticated, bounded WebSocket transport for the serial session broker. */
|
|
|
|
#include "web_serial_transport.h"
|
|
|
|
#include <inttypes.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
|
|
#include "esp_timer.h"
|
|
#include "freertos/FreeRTOS.h"
|
|
#include "freertos/task.h"
|
|
#include "mbedtls/sha256.h"
|
|
#include "sdkconfig.h"
|
|
#include "secure_random.h"
|
|
#include "serial_service.h"
|
|
#include "web_security.h"
|
|
|
|
#if !defined(CONFIG_HTTPD_WS_SUPPORT) || !CONFIG_HTTPD_WS_SUPPORT
|
|
#error "web_serial_transport requires CONFIG_HTTPD_WS_SUPPORT"
|
|
#endif
|
|
|
|
#define WEB_SERIAL_RANDOM_BYTES 24U
|
|
#define WEB_SERIAL_SHA256_BYTES 32U
|
|
#define WEB_SERIAL_QUERY_CAPACITY 48U
|
|
#define WEB_SERIAL_HOST_CAPACITY 128U
|
|
#define WEB_SERIAL_ORIGIN_CAPACITY (sizeof("https://") + WEB_SERIAL_HOST_CAPACITY)
|
|
#define WEB_SERIAL_TICKET_RESPONSE_CAPACITY 96U
|
|
#define WEB_SERIAL_TASK_STACK_SIZE 6144U
|
|
#define WEB_SERIAL_TASK_PRIORITY 7U
|
|
#define WEB_SERIAL_POLL_MS 5U
|
|
#define WEB_SERIAL_DETACH_TIMEOUT_US 1000000LL
|
|
|
|
_Static_assert(WEB_SERIAL_TRANSPORT_TICKET_LENGTH ==
|
|
(WEB_SERIAL_RANDOM_BYTES * 4U) / 3U,
|
|
"24 random bytes must encode as 32 unpadded Base64URL bytes");
|
|
_Static_assert(WEB_SERIAL_TRANSPORT_TX_PAYLOAD_SIZE <= 512U,
|
|
"WebSocket TX buffers must remain bounded to 512 bytes");
|
|
|
|
typedef enum {
|
|
WEB_SERIAL_SLOT_FREE = 0,
|
|
WEB_SERIAL_SLOT_RESERVED,
|
|
WEB_SERIAL_SLOT_ACTIVE,
|
|
WEB_SERIAL_SLOT_CLOSING,
|
|
} web_serial_slot_state_t;
|
|
|
|
typedef struct {
|
|
uint8_t digest[WEB_SERIAL_SHA256_BYTES];
|
|
int64_t expires_at_us;
|
|
uint32_t material_generation;
|
|
bool active;
|
|
} web_serial_ticket_t;
|
|
|
|
struct web_serial_slot;
|
|
|
|
typedef struct {
|
|
struct web_serial_slot *slot;
|
|
httpd_handle_t server;
|
|
int socket_fd;
|
|
uint32_t generation;
|
|
} web_serial_work_t;
|
|
|
|
typedef struct web_serial_slot {
|
|
web_serial_slot_state_t state;
|
|
httpd_handle_t server;
|
|
int socket_fd;
|
|
uint32_t generation;
|
|
session_broker_client_id_t broker_client_id;
|
|
bool writer;
|
|
bool hello_pending;
|
|
bool work_pending;
|
|
bool close_requested;
|
|
bool close_triggered;
|
|
int64_t close_retry_at_us;
|
|
bool disconnect_busy;
|
|
httpd_ws_type_t tx_type;
|
|
size_t tx_length;
|
|
uint8_t rx_data[WEB_SERIAL_TRANSPORT_MAX_RX_PAYLOAD];
|
|
uint8_t tx_data[WEB_SERIAL_TRANSPORT_TX_PAYLOAD_SIZE];
|
|
web_serial_work_t work;
|
|
} web_serial_slot_t;
|
|
|
|
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
|
static bool s_initialized;
|
|
static httpd_handle_t s_server;
|
|
static TaskHandle_t s_transport_task;
|
|
static StaticTask_t s_transport_task_storage;
|
|
static StackType_t
|
|
s_transport_task_stack[WEB_SERIAL_TASK_STACK_SIZE / sizeof(StackType_t)];
|
|
static web_serial_ticket_t s_tickets[WEB_SERIAL_TRANSPORT_MAX_TICKETS];
|
|
static web_serial_slot_t s_slots[WEB_SERIAL_TRANSPORT_MAX_SESSIONS];
|
|
static web_serial_transport_counters_t s_counters;
|
|
static uint32_t s_httpd_close_operations;
|
|
static uint32_t s_inflight_handlers;
|
|
|
|
static TickType_t milliseconds_to_ticks(uint32_t milliseconds)
|
|
{
|
|
TickType_t ticks = pdMS_TO_TICKS(milliseconds);
|
|
return (milliseconds > 0U && ticks == 0U) ? 1U : ticks;
|
|
}
|
|
|
|
static int64_t monotonic_time_us(void)
|
|
{
|
|
return esp_timer_get_time();
|
|
}
|
|
|
|
static void notify_transport_task(void)
|
|
{
|
|
TaskHandle_t task;
|
|
taskENTER_CRITICAL(&s_lock);
|
|
task = s_transport_task;
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
if (task != NULL) {
|
|
xTaskNotifyGive(task);
|
|
}
|
|
}
|
|
|
|
static void add_counter(uint64_t *counter, uint64_t amount)
|
|
{
|
|
taskENTER_CRITICAL(&s_lock);
|
|
*counter += amount;
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
}
|
|
|
|
static uint32_t next_generation(uint32_t generation)
|
|
{
|
|
++generation;
|
|
return generation == 0U ? 1U : generation;
|
|
}
|
|
|
|
static bool slot_pointer_valid(const web_serial_slot_t *slot)
|
|
{
|
|
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++index) {
|
|
if (slot == &s_slots[index]) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static void clear_ticket_locked(web_serial_ticket_t *ticket)
|
|
{
|
|
secure_wipe(ticket->digest, sizeof(ticket->digest));
|
|
ticket->expires_at_us = 0;
|
|
ticket->material_generation = 0U;
|
|
ticket->active = false;
|
|
}
|
|
|
|
static void clear_all_tickets_locked(void)
|
|
{
|
|
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++index) {
|
|
clear_ticket_locked(&s_tickets[index]);
|
|
}
|
|
}
|
|
|
|
static void purge_tickets_locked(int64_t now_us, uint32_t material_generation,
|
|
bool check_generation)
|
|
{
|
|
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++index) {
|
|
web_serial_ticket_t *ticket = &s_tickets[index];
|
|
if (!ticket->active) {
|
|
continue;
|
|
}
|
|
if (ticket->expires_at_us <= now_us) {
|
|
clear_ticket_locked(ticket);
|
|
++s_counters.tickets_expired;
|
|
} else if (check_generation &&
|
|
ticket->material_generation != material_generation) {
|
|
clear_ticket_locked(ticket);
|
|
}
|
|
}
|
|
}
|
|
|
|
static bool constant_time_equal(const uint8_t *left, const uint8_t *right,
|
|
size_t length)
|
|
{
|
|
uint8_t difference = 0U;
|
|
for (size_t index = 0U; index < length; ++index) {
|
|
difference |= left[index] ^ right[index];
|
|
}
|
|
return difference == 0U;
|
|
}
|
|
|
|
static void encode_base64url_24(const uint8_t input[WEB_SERIAL_RANDOM_BYTES],
|
|
char output[WEB_SERIAL_TRANSPORT_TICKET_CAPACITY])
|
|
{
|
|
static const char alphabet[] =
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
|
|
size_t output_offset = 0U;
|
|
for (size_t input_offset = 0U; input_offset < WEB_SERIAL_RANDOM_BYTES;
|
|
input_offset += 3U) {
|
|
uint32_t value = ((uint32_t)input[input_offset] << 16U) |
|
|
((uint32_t)input[input_offset + 1U] << 8U) |
|
|
input[input_offset + 2U];
|
|
output[output_offset++] = alphabet[(value >> 18U) & 0x3fU];
|
|
output[output_offset++] = alphabet[(value >> 12U) & 0x3fU];
|
|
output[output_offset++] = alphabet[(value >> 6U) & 0x3fU];
|
|
output[output_offset++] = alphabet[value & 0x3fU];
|
|
}
|
|
output[output_offset] = '\0';
|
|
}
|
|
|
|
static bool base64url_character(char value)
|
|
{
|
|
return (value >= 'A' && value <= 'Z') ||
|
|
(value >= 'a' && value <= 'z') ||
|
|
(value >= '0' && value <= '9') || value == '-' || value == '_';
|
|
}
|
|
|
|
static esp_err_t current_material_generation(uint32_t *generation)
|
|
{
|
|
web_security_certificate_metadata_t metadata;
|
|
esp_err_t result = web_security_get_certificate_metadata(&metadata);
|
|
if (result == ESP_OK) {
|
|
*generation = metadata.material_generation;
|
|
}
|
|
secure_wipe(&metadata, sizeof(metadata));
|
|
return result;
|
|
}
|
|
|
|
static esp_err_t sha256_ticket(const char *ticket,
|
|
uint8_t digest[WEB_SERIAL_SHA256_BYTES])
|
|
{
|
|
return mbedtls_sha256((const unsigned char *)ticket,
|
|
WEB_SERIAL_TRANSPORT_TICKET_LENGTH,
|
|
digest, 0) == 0
|
|
? ESP_OK
|
|
: ESP_FAIL;
|
|
}
|
|
|
|
static esp_err_t extract_ticket_query(httpd_req_t *request,
|
|
char ticket[WEB_SERIAL_TRANSPORT_TICKET_CAPACITY])
|
|
{
|
|
char query[WEB_SERIAL_QUERY_CAPACITY] = {0};
|
|
const char prefix[] = WEB_SERIAL_TRANSPORT_TICKET_QUERY_KEY "=";
|
|
size_t query_length = httpd_req_get_url_query_len(request);
|
|
esp_err_t result = ESP_ERR_INVALID_ARG;
|
|
|
|
if (query_length == 0U || query_length >= sizeof(query) ||
|
|
httpd_req_get_url_query_str(request, query, sizeof(query)) != ESP_OK) {
|
|
goto cleanup;
|
|
}
|
|
|
|
size_t prefix_length = sizeof(prefix) - 1U;
|
|
if (query_length != prefix_length + WEB_SERIAL_TRANSPORT_TICKET_LENGTH ||
|
|
memcmp(query, prefix, prefix_length) != 0) {
|
|
goto cleanup;
|
|
}
|
|
|
|
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_TICKET_LENGTH; ++index) {
|
|
char value = query[prefix_length + index];
|
|
if (!base64url_character(value)) {
|
|
goto cleanup;
|
|
}
|
|
ticket[index] = value;
|
|
}
|
|
ticket[WEB_SERIAL_TRANSPORT_TICKET_LENGTH] = '\0';
|
|
result = ESP_OK;
|
|
|
|
cleanup:
|
|
secure_wipe(query, sizeof(query));
|
|
return result;
|
|
}
|
|
|
|
static esp_err_t validate_origin(httpd_req_t *request)
|
|
{
|
|
char origin[WEB_SERIAL_ORIGIN_CAPACITY] = {0};
|
|
char host[WEB_SERIAL_HOST_CAPACITY] = {0};
|
|
char expected[WEB_SERIAL_ORIGIN_CAPACITY] = {0};
|
|
|
|
esp_err_t result = httpd_req_get_hdr_value_str(
|
|
request, "Origin", origin, sizeof(origin));
|
|
if (result == ESP_ERR_NOT_FOUND) {
|
|
return ESP_OK;
|
|
}
|
|
if (result != ESP_OK) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
size_t host_length = httpd_req_get_hdr_value_len(request, "Host");
|
|
if (host_length == 0U || host_length >= sizeof(host) ||
|
|
httpd_req_get_hdr_value_str(request, "Host", host, sizeof(host)) != ESP_OK) {
|
|
secure_wipe(origin, sizeof(origin));
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
int written = snprintf(expected, sizeof(expected), "https://%s", host);
|
|
bool matches = written > 0 && (size_t)written < sizeof(expected) &&
|
|
strcmp(origin, expected) == 0;
|
|
secure_wipe(origin, sizeof(origin));
|
|
secure_wipe(host, sizeof(host));
|
|
secure_wipe(expected, sizeof(expected));
|
|
return matches ? ESP_OK : ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
static esp_err_t consume_ticket(const char *ticket, bool *consumed)
|
|
{
|
|
uint8_t digest[WEB_SERIAL_SHA256_BYTES] = {0};
|
|
uint32_t generation = 0U;
|
|
*consumed = false;
|
|
|
|
esp_err_t result = current_material_generation(&generation);
|
|
if (result == ESP_OK) {
|
|
result = sha256_ticket(ticket, digest);
|
|
}
|
|
if (result != ESP_OK) {
|
|
secure_wipe(digest, sizeof(digest));
|
|
add_counter(&s_counters.tickets_rejected, 1U);
|
|
return result;
|
|
}
|
|
|
|
int64_t now_us = monotonic_time_us();
|
|
size_t matching_index = WEB_SERIAL_TRANSPORT_MAX_TICKETS;
|
|
size_t matching_count = 0U;
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++index) {
|
|
web_serial_ticket_t *entry = &s_tickets[index];
|
|
bool digest_matches =
|
|
constant_time_equal(entry->digest, digest, sizeof(digest));
|
|
if (entry->active && digest_matches) {
|
|
matching_index = index;
|
|
++matching_count;
|
|
}
|
|
}
|
|
|
|
purge_tickets_locked(now_us, generation, true);
|
|
if (matching_count == 1U &&
|
|
matching_index < WEB_SERIAL_TRANSPORT_MAX_TICKETS) {
|
|
web_serial_ticket_t *entry = &s_tickets[matching_index];
|
|
if (entry->active && entry->expires_at_us > now_us &&
|
|
entry->material_generation == generation) {
|
|
clear_ticket_locked(entry);
|
|
++s_counters.tickets_consumed;
|
|
*consumed = true;
|
|
}
|
|
}
|
|
if (!*consumed) {
|
|
/* A defensive duplicate can never become a repeatedly usable ticket. */
|
|
if (matching_count > 1U) {
|
|
for (size_t index = 0U;
|
|
index < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++index) {
|
|
web_serial_ticket_t *entry = &s_tickets[index];
|
|
if (entry->active && constant_time_equal(
|
|
entry->digest, digest,
|
|
sizeof(digest))) {
|
|
clear_ticket_locked(entry);
|
|
}
|
|
}
|
|
}
|
|
++s_counters.tickets_rejected;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
|
|
secure_wipe(digest, sizeof(digest));
|
|
return ESP_OK;
|
|
}
|
|
|
|
static web_serial_slot_t *reserve_slot(httpd_handle_t server, int socket_fd,
|
|
uint32_t *generation)
|
|
{
|
|
web_serial_slot_t *selected = NULL;
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (s_initialized && s_server == server) {
|
|
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS;
|
|
++index) {
|
|
web_serial_slot_t *slot = &s_slots[index];
|
|
if (slot->state != WEB_SERIAL_SLOT_FREE) {
|
|
continue;
|
|
}
|
|
slot->generation = next_generation(slot->generation);
|
|
slot->state = WEB_SERIAL_SLOT_RESERVED;
|
|
slot->server = server;
|
|
slot->socket_fd = socket_fd;
|
|
slot->broker_client_id = SESSION_BROKER_NO_CLIENT;
|
|
slot->writer = false;
|
|
slot->hello_pending = false;
|
|
slot->work_pending = false;
|
|
slot->close_requested = false;
|
|
slot->close_triggered = false;
|
|
slot->close_retry_at_us = 0;
|
|
slot->disconnect_busy = false;
|
|
slot->tx_length = 0U;
|
|
selected = slot;
|
|
*generation = slot->generation;
|
|
break;
|
|
}
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
return selected;
|
|
}
|
|
|
|
static void make_slot_free_locked(web_serial_slot_t *slot)
|
|
{
|
|
slot->state = WEB_SERIAL_SLOT_FREE;
|
|
slot->server = NULL;
|
|
slot->socket_fd = -1;
|
|
slot->broker_client_id = SESSION_BROKER_NO_CLIENT;
|
|
slot->writer = false;
|
|
slot->hello_pending = false;
|
|
slot->work_pending = false;
|
|
slot->close_requested = false;
|
|
slot->close_triggered = false;
|
|
slot->close_retry_at_us = 0;
|
|
slot->disconnect_busy = false;
|
|
slot->tx_length = 0U;
|
|
}
|
|
|
|
static void release_reserved_slot(web_serial_slot_t *slot, uint32_t generation)
|
|
{
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->state == WEB_SERIAL_SLOT_RESERVED &&
|
|
slot->generation == generation) {
|
|
make_slot_free_locked(slot);
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
}
|
|
|
|
static void close_unpublished_broker_session(web_serial_slot_t *slot,
|
|
uint32_t generation,
|
|
session_broker_client_id_t client_id)
|
|
{
|
|
esp_err_t result = session_broker_disconnect(client_id);
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->state == WEB_SERIAL_SLOT_RESERVED &&
|
|
slot->generation == generation) {
|
|
if (result == ESP_OK || result == ESP_ERR_NOT_FOUND) {
|
|
make_slot_free_locked(slot);
|
|
} else {
|
|
slot->state = WEB_SERIAL_SLOT_CLOSING;
|
|
slot->broker_client_id = client_id;
|
|
++s_counters.broker_failures;
|
|
}
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
if (result != ESP_OK && result != ESP_ERR_NOT_FOUND) {
|
|
notify_transport_task();
|
|
}
|
|
}
|
|
|
|
static void request_handler_close(web_serial_slot_t *slot, uint32_t generation)
|
|
{
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->generation == generation &&
|
|
(slot->state == WEB_SERIAL_SLOT_ACTIVE ||
|
|
slot->state == WEB_SERIAL_SLOT_CLOSING)) {
|
|
slot->close_requested = true;
|
|
if (!slot->close_triggered) {
|
|
/* Returning ESP_FAIL closes this request; do not also queue a close. */
|
|
slot->close_triggered = true;
|
|
++s_counters.close_requests;
|
|
}
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
}
|
|
|
|
static esp_err_t reject_protocol_frame(web_serial_slot_t *slot,
|
|
uint32_t generation, size_t payload_length)
|
|
{
|
|
taskENTER_CRITICAL(&s_lock);
|
|
++s_counters.rx_ws_frames_rejected;
|
|
s_counters.rx_ws_bytes_rejected += payload_length;
|
|
++s_counters.protocol_errors;
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
request_handler_close(slot, generation);
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
static void web_serial_session_free(void *context)
|
|
{
|
|
web_serial_slot_t *slot = context;
|
|
if (!slot_pointer_valid(slot)) {
|
|
return;
|
|
}
|
|
|
|
bool notify = false;
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->state == WEB_SERIAL_SLOT_ACTIVE) {
|
|
slot->state = WEB_SERIAL_SLOT_CLOSING;
|
|
slot->writer = false;
|
|
slot->hello_pending = false;
|
|
slot->close_requested = false;
|
|
slot->close_triggered = true;
|
|
slot->close_retry_at_us = 0;
|
|
++s_counters.disconnections;
|
|
notify = true;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
|
|
if (notify) {
|
|
notify_transport_task();
|
|
}
|
|
}
|
|
|
|
static esp_err_t send_plain_bad_request(httpd_req_t *request)
|
|
{
|
|
static const char message[] = "WebSocket upgrade required.\n";
|
|
esp_err_t result = httpd_resp_set_status(request, "400 Bad Request");
|
|
if (result == ESP_OK) {
|
|
result = httpd_resp_set_type(request, "text/plain; charset=utf-8");
|
|
}
|
|
if (result == ESP_OK) {
|
|
result = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
|
}
|
|
if (result == ESP_OK) {
|
|
result = httpd_resp_send(request, message, sizeof(message) - 1U);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
|
|
{
|
|
char ticket[WEB_SERIAL_TRANSPORT_TICKET_CAPACITY] = {0};
|
|
uint32_t slot_generation = 0U;
|
|
web_serial_slot_t *slot = NULL;
|
|
bool consumed = false;
|
|
esp_err_t result;
|
|
|
|
if (validate_origin(request) != ESP_OK ||
|
|
extract_ticket_query(request, ticket) != ESP_OK) {
|
|
add_counter(&s_counters.tickets_rejected, 1U);
|
|
result = ESP_FAIL;
|
|
goto cleanup;
|
|
}
|
|
|
|
slot = reserve_slot(request->handle, socket_fd, &slot_generation);
|
|
if (slot == NULL) {
|
|
add_counter(&s_counters.connection_failures, 1U);
|
|
result = ESP_FAIL;
|
|
goto cleanup;
|
|
}
|
|
|
|
result = consume_ticket(ticket, &consumed);
|
|
if (result != ESP_OK || !consumed) {
|
|
release_reserved_slot(slot, slot_generation);
|
|
result = ESP_FAIL;
|
|
goto cleanup;
|
|
}
|
|
|
|
if (!serial_service_is_running()) {
|
|
result = serial_service_start();
|
|
/* Another permanent transport may have won the start race. */
|
|
if (result != ESP_OK && serial_service_is_running()) {
|
|
result = ESP_OK;
|
|
}
|
|
if (result != ESP_OK) {
|
|
add_counter(&s_counters.service_start_failures, 1U);
|
|
add_counter(&s_counters.connection_failures, 1U);
|
|
release_reserved_slot(slot, slot_generation);
|
|
result = ESP_FAIL;
|
|
goto cleanup;
|
|
}
|
|
}
|
|
|
|
size_t slot_index = (size_t)(slot - s_slots);
|
|
char client_name[SESSION_BROKER_CLIENT_NAME_MAX + 1U];
|
|
int written = snprintf(client_name, sizeof(client_name),
|
|
"web-%u-%" PRIu32,
|
|
(unsigned int)slot_index, slot_generation);
|
|
if (written <= 0 || (size_t)written >= sizeof(client_name)) {
|
|
add_counter(&s_counters.connection_failures, 1U);
|
|
release_reserved_slot(slot, slot_generation);
|
|
result = ESP_FAIL;
|
|
goto cleanup;
|
|
}
|
|
|
|
session_broker_client_id_t client_id = SESSION_BROKER_NO_CLIENT;
|
|
result = session_broker_connect(SESSION_BROKER_CLIENT_WEB,
|
|
client_name, &client_id);
|
|
if (result != ESP_OK) {
|
|
add_counter(&s_counters.connection_failures, 1U);
|
|
add_counter(&s_counters.broker_failures, 1U);
|
|
release_reserved_slot(slot, slot_generation);
|
|
result = ESP_FAIL;
|
|
goto cleanup;
|
|
}
|
|
|
|
add_counter(&s_counters.writer_requests, 1U);
|
|
result = session_broker_request_writer(client_id);
|
|
bool writer = result == ESP_OK;
|
|
if (writer) {
|
|
add_counter(&s_counters.writer_grants, 1U);
|
|
} else if (result == ESP_ERR_INVALID_STATE) {
|
|
add_counter(&s_counters.writer_denials, 1U);
|
|
} else {
|
|
add_counter(&s_counters.connection_failures, 1U);
|
|
add_counter(&s_counters.broker_failures, 1U);
|
|
close_unpublished_broker_session(slot, slot_generation, client_id);
|
|
result = ESP_FAIL;
|
|
goto cleanup;
|
|
}
|
|
|
|
bool activated = false;
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->state == WEB_SERIAL_SLOT_RESERVED &&
|
|
slot->generation == slot_generation &&
|
|
s_server == request->handle) {
|
|
slot->state = WEB_SERIAL_SLOT_ACTIVE;
|
|
slot->broker_client_id = client_id;
|
|
slot->writer = writer;
|
|
slot->hello_pending = true;
|
|
++s_counters.connections;
|
|
activated = true;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
|
|
if (!activated) {
|
|
add_counter(&s_counters.connection_failures, 1U);
|
|
close_unpublished_broker_session(slot, slot_generation, client_id);
|
|
result = ESP_FAIL;
|
|
goto cleanup;
|
|
}
|
|
|
|
/* Static context is published only after service and broker setup succeed. */
|
|
request->sess_ctx = slot;
|
|
request->free_ctx = web_serial_session_free;
|
|
notify_transport_task();
|
|
result = ESP_OK;
|
|
|
|
cleanup:
|
|
secure_wipe(ticket, sizeof(ticket));
|
|
return result;
|
|
}
|
|
|
|
static bool capture_active_session(httpd_req_t *request, web_serial_slot_t **slot_out,
|
|
uint32_t *generation,
|
|
session_broker_client_id_t *client_id)
|
|
{
|
|
web_serial_slot_t *slot = request->sess_ctx;
|
|
int socket_fd = httpd_req_to_sockfd(request);
|
|
if (!slot_pointer_valid(slot) || socket_fd < 0) {
|
|
return false;
|
|
}
|
|
|
|
bool valid;
|
|
taskENTER_CRITICAL(&s_lock);
|
|
valid = slot->state == WEB_SERIAL_SLOT_ACTIVE &&
|
|
s_server == request->handle &&
|
|
slot->server == request->handle &&
|
|
slot->socket_fd == socket_fd &&
|
|
slot->broker_client_id != SESSION_BROKER_NO_CLIENT;
|
|
if (valid) {
|
|
*slot_out = slot;
|
|
*generation = slot->generation;
|
|
*client_id = slot->broker_client_id;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
return valid;
|
|
}
|
|
|
|
static void account_accepted_frame(size_t accepted, size_t rejected,
|
|
bool complete)
|
|
{
|
|
taskENTER_CRITICAL(&s_lock);
|
|
s_counters.rx_ws_bytes_accepted += accepted;
|
|
s_counters.rx_ws_bytes_rejected += rejected;
|
|
if (complete) {
|
|
++s_counters.rx_ws_frames_accepted;
|
|
} else {
|
|
++s_counters.rx_ws_frames_rejected;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
}
|
|
|
|
static esp_err_t process_binary_frame(web_serial_slot_t *slot,
|
|
uint32_t generation,
|
|
session_broker_client_id_t client_id,
|
|
const uint8_t *payload, size_t length)
|
|
{
|
|
size_t accepted = 0U;
|
|
esp_err_t result = session_broker_write(client_id, payload, length, &accepted);
|
|
if (accepted > length) {
|
|
accepted = 0U;
|
|
result = ESP_FAIL;
|
|
}
|
|
|
|
account_accepted_frame(accepted, length - accepted,
|
|
result == ESP_OK && accepted == length);
|
|
if (result == ESP_ERR_NOT_FOUND) {
|
|
add_counter(&s_counters.broker_failures, 1U);
|
|
request_handler_close(slot, generation);
|
|
return ESP_FAIL;
|
|
}
|
|
if (result != ESP_OK && result != ESP_ERR_INVALID_STATE &&
|
|
result != ESP_ERR_TIMEOUT) {
|
|
add_counter(&s_counters.broker_failures, 1U);
|
|
}
|
|
return ESP_OK;
|
|
}
|
|
|
|
static esp_err_t process_text_frame(web_serial_slot_t *slot,
|
|
uint32_t generation,
|
|
session_broker_client_id_t client_id,
|
|
const uint8_t *payload, size_t length)
|
|
{
|
|
static const char request_writer[] = "request-writer";
|
|
static const char release_writer[] = "release-writer";
|
|
esp_err_t result;
|
|
|
|
if (length == sizeof(request_writer) - 1U &&
|
|
memcmp(payload, request_writer, length) == 0) {
|
|
bool was_writer;
|
|
taskENTER_CRITICAL(&s_lock);
|
|
was_writer = slot->generation == generation && slot->writer;
|
|
++s_counters.writer_requests;
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
|
|
result = session_broker_request_writer(client_id);
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->generation == generation && result == ESP_OK) {
|
|
slot->writer = true;
|
|
}
|
|
if (result == ESP_OK && !was_writer) {
|
|
++s_counters.writer_grants;
|
|
} else if (result == ESP_ERR_INVALID_STATE) {
|
|
++s_counters.writer_denials;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
|
|
account_accepted_frame(length, 0U, true);
|
|
if (result == ESP_ERR_NOT_FOUND) {
|
|
add_counter(&s_counters.broker_failures, 1U);
|
|
request_handler_close(slot, generation);
|
|
return ESP_FAIL;
|
|
}
|
|
return ESP_OK;
|
|
}
|
|
|
|
if (length == sizeof(release_writer) - 1U &&
|
|
memcmp(payload, release_writer, length) == 0) {
|
|
result = session_broker_release_writer(client_id);
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->generation == generation && result == ESP_OK) {
|
|
slot->writer = false;
|
|
++s_counters.writer_releases;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
|
|
account_accepted_frame(length, 0U, true);
|
|
if (result == ESP_ERR_NOT_FOUND) {
|
|
add_counter(&s_counters.broker_failures, 1U);
|
|
request_handler_close(slot, generation);
|
|
return ESP_FAIL;
|
|
}
|
|
return ESP_OK;
|
|
}
|
|
|
|
return reject_protocol_frame(slot, generation, length);
|
|
}
|
|
|
|
static esp_err_t process_websocket_frame(httpd_req_t *request)
|
|
{
|
|
web_serial_slot_t *slot = NULL;
|
|
uint32_t generation = 0U;
|
|
session_broker_client_id_t client_id = SESSION_BROKER_NO_CLIENT;
|
|
if (!capture_active_session(request, &slot, &generation, &client_id)) {
|
|
add_counter(&s_counters.protocol_errors, 1U);
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
int socket_fd = httpd_req_to_sockfd(request);
|
|
if (httpd_ws_get_fd_info(request->handle, socket_fd) !=
|
|
HTTPD_WS_CLIENT_WEBSOCKET) {
|
|
return reject_protocol_frame(slot, generation, 0U);
|
|
}
|
|
|
|
httpd_ws_frame_t frame = {0};
|
|
esp_err_t result = httpd_ws_recv_frame(request, &frame, 0U);
|
|
if (result != ESP_OK) {
|
|
return reject_protocol_frame(slot, generation, frame.len);
|
|
}
|
|
if (!frame.final || frame.type == HTTPD_WS_TYPE_CONTINUE ||
|
|
frame.len > WEB_SERIAL_TRANSPORT_MAX_RX_PAYLOAD ||
|
|
(frame.type != HTTPD_WS_TYPE_BINARY &&
|
|
frame.type != HTTPD_WS_TYPE_TEXT)) {
|
|
return reject_protocol_frame(slot, generation, frame.len);
|
|
}
|
|
|
|
frame.payload = slot->rx_data;
|
|
result = httpd_ws_recv_frame(request, &frame, sizeof(slot->rx_data));
|
|
if (result != ESP_OK) {
|
|
return reject_protocol_frame(slot, generation, frame.len);
|
|
}
|
|
|
|
if (frame.type == HTTPD_WS_TYPE_BINARY) {
|
|
return process_binary_frame(slot, generation, client_id,
|
|
slot->rx_data, frame.len);
|
|
}
|
|
return process_text_frame(slot, generation, client_id,
|
|
slot->rx_data, frame.len);
|
|
}
|
|
|
|
static void finish_closing_slot_locked(web_serial_slot_t *slot)
|
|
{
|
|
if (slot->state == WEB_SERIAL_SLOT_CLOSING &&
|
|
slot->broker_client_id == SESSION_BROKER_NO_CLIENT &&
|
|
!slot->work_pending) {
|
|
make_slot_free_locked(slot);
|
|
}
|
|
}
|
|
|
|
static void web_serial_send_work(void *argument)
|
|
{
|
|
web_serial_work_t *work = argument;
|
|
web_serial_slot_t *slot = work != NULL ? work->slot : NULL;
|
|
if (!slot_pointer_valid(slot)) {
|
|
return;
|
|
}
|
|
|
|
httpd_handle_t server;
|
|
int socket_fd;
|
|
uint32_t generation;
|
|
httpd_ws_type_t type;
|
|
size_t length;
|
|
bool valid;
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
valid = slot->work_pending && work == &slot->work &&
|
|
work->generation == slot->generation &&
|
|
work->server == slot->server &&
|
|
work->socket_fd == slot->socket_fd;
|
|
server = work->server;
|
|
socket_fd = work->socket_fd;
|
|
generation = work->generation;
|
|
type = slot->tx_type;
|
|
length = slot->tx_length;
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
if (!valid) {
|
|
return;
|
|
}
|
|
|
|
esp_err_t result = ESP_FAIL;
|
|
void *current_context = httpd_sess_get_ctx(server, socket_fd);
|
|
if (current_context == slot &&
|
|
httpd_ws_get_fd_info(server, socket_fd) ==
|
|
HTTPD_WS_CLIENT_WEBSOCKET) {
|
|
httpd_ws_frame_t frame = {
|
|
.final = true,
|
|
.fragmented = false,
|
|
.type = type,
|
|
.payload = slot->tx_data,
|
|
.len = length,
|
|
};
|
|
result = httpd_ws_send_frame_async(server, socket_fd, &frame);
|
|
if (result == ESP_OK) {
|
|
(void)httpd_sess_update_lru_counter(server, socket_fd);
|
|
}
|
|
}
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->work_pending && slot->generation == generation &&
|
|
work == &slot->work) {
|
|
slot->work_pending = false;
|
|
slot->tx_length = 0U;
|
|
if (result == ESP_OK) {
|
|
if (type == HTTPD_WS_TYPE_BINARY) {
|
|
++s_counters.tx_binary_frames;
|
|
s_counters.tx_binary_bytes += length;
|
|
} else {
|
|
++s_counters.tx_control_frames;
|
|
s_counters.tx_control_bytes += length;
|
|
}
|
|
} else {
|
|
++s_counters.send_failures;
|
|
slot->close_requested = true;
|
|
}
|
|
finish_closing_slot_locked(slot);
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
notify_transport_task();
|
|
}
|
|
|
|
static esp_err_t queue_slot_frame(web_serial_slot_t *slot, uint32_t generation,
|
|
httpd_ws_type_t type, size_t length)
|
|
{
|
|
httpd_handle_t server;
|
|
bool prepared = false;
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->state == WEB_SERIAL_SLOT_ACTIVE &&
|
|
slot->generation == generation && !slot->work_pending &&
|
|
!slot->close_requested && slot->server == s_server &&
|
|
length <= sizeof(slot->tx_data)) {
|
|
slot->tx_type = type;
|
|
slot->tx_length = length;
|
|
slot->work.slot = slot;
|
|
slot->work.server = slot->server;
|
|
slot->work.socket_fd = slot->socket_fd;
|
|
slot->work.generation = generation;
|
|
slot->work_pending = true;
|
|
server = slot->server;
|
|
prepared = true;
|
|
} else {
|
|
server = NULL;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
|
|
if (!prepared) {
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
|
|
#if defined(CONFIG_HTTPD_QUEUE_WORK_BLOCKING) && CONFIG_HTTPD_QUEUE_WORK_BLOCKING
|
|
esp_err_t result = ESP_ERR_NOT_SUPPORTED;
|
|
#else
|
|
esp_err_t result = httpd_queue_work(server, web_serial_send_work,
|
|
&slot->work);
|
|
#endif
|
|
if (result != ESP_OK) {
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->work_pending && slot->generation == generation) {
|
|
slot->work_pending = false;
|
|
slot->tx_length = 0U;
|
|
slot->close_requested = true;
|
|
++s_counters.queue_failures;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
notify_transport_task();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
static const char *writer_event_name(session_broker_event_type_t type)
|
|
{
|
|
switch (type) {
|
|
case SESSION_BROKER_EVENT_WRITER_GRANTED:
|
|
return "granted";
|
|
case SESSION_BROKER_EVENT_WRITER_RELEASED:
|
|
return "released";
|
|
case SESSION_BROKER_EVENT_WRITER_REVOKED:
|
|
return "revoked";
|
|
case SESSION_BROKER_EVENT_WRITER_DENIED:
|
|
return "denied";
|
|
default:
|
|
return NULL;
|
|
}
|
|
}
|
|
|
|
static bool prepare_hello(web_serial_slot_t *slot, uint32_t generation,
|
|
session_broker_client_id_t client_id)
|
|
{
|
|
session_broker_client_id_t writer_id = session_broker_get_writer_id();
|
|
bool writer = writer_id == client_id;
|
|
int written = snprintf(
|
|
(char *)slot->tx_data, sizeof(slot->tx_data),
|
|
"{\"type\":\"hello\",\"clientId\":%" PRIu32
|
|
",\"role\":\"%s\",\"writerId\":%" PRIu32 "}",
|
|
client_id, writer ? "writer" : "observer", writer_id);
|
|
if (written <= 0 || (size_t)written >= sizeof(slot->tx_data)) {
|
|
return false;
|
|
}
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->generation == generation &&
|
|
slot->state == WEB_SERIAL_SLOT_ACTIVE) {
|
|
slot->writer = writer;
|
|
slot->hello_pending = false;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
return queue_slot_frame(slot, generation, HTTPD_WS_TYPE_TEXT,
|
|
(size_t)written) == ESP_OK;
|
|
}
|
|
|
|
static bool prepare_writer_event(web_serial_slot_t *slot, uint32_t generation,
|
|
session_broker_client_id_t client_id)
|
|
{
|
|
session_broker_event_t event;
|
|
for (size_t count = 0U; count < SESSION_BROKER_EVENT_QUEUE_LENGTH; ++count) {
|
|
esp_err_t result = session_broker_pop_event(client_id, &event);
|
|
if (result == ESP_ERR_TIMEOUT) {
|
|
return false;
|
|
}
|
|
if (result != ESP_OK) {
|
|
if (result == ESP_ERR_NOT_FOUND) {
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->generation == generation) {
|
|
slot->close_requested = true;
|
|
++s_counters.broker_failures;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
session_broker_client_id_t writer_id = session_broker_get_writer_id();
|
|
bool writer = writer_id == client_id;
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->generation == generation &&
|
|
slot->state == WEB_SERIAL_SLOT_ACTIVE) {
|
|
slot->writer = writer;
|
|
if (event.type == SESSION_BROKER_EVENT_WRITER_REVOKED &&
|
|
event.client_id == client_id) {
|
|
++s_counters.writer_revocations;
|
|
}
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
|
|
const char *event_name = writer_event_name(event.type);
|
|
if (event_name == NULL) {
|
|
continue;
|
|
}
|
|
|
|
int written = snprintf(
|
|
(char *)slot->tx_data, sizeof(slot->tx_data),
|
|
"{\"type\":\"writer\",\"event\":\"%s\","
|
|
"\"sequence\":%" PRIu64 ",\"clientId\":%" PRIu32
|
|
",\"role\":\"%s\",\"writerId\":%" PRIu32 "}",
|
|
event_name, event.sequence, event.client_id,
|
|
writer ? "writer" : "observer", writer_id);
|
|
if (written <= 0 || (size_t)written >= sizeof(slot->tx_data)) {
|
|
taskENTER_CRITICAL(&s_lock);
|
|
++s_counters.protocol_errors;
|
|
if (slot->generation == generation) {
|
|
slot->close_requested = true;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
return false;
|
|
}
|
|
return queue_slot_frame(slot, generation, HTTPD_WS_TYPE_TEXT,
|
|
(size_t)written) == ESP_OK;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool prepare_writer_sync(web_serial_slot_t *slot, uint32_t generation,
|
|
session_broker_client_id_t client_id)
|
|
{
|
|
session_broker_client_id_t writer_id = session_broker_get_writer_id();
|
|
bool writer = writer_id == client_id;
|
|
bool changed = false;
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->generation == generation &&
|
|
slot->state == WEB_SERIAL_SLOT_ACTIVE && slot->writer != writer) {
|
|
slot->writer = writer;
|
|
changed = true;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
if (!changed) {
|
|
return false;
|
|
}
|
|
|
|
int written = snprintf(
|
|
(char *)slot->tx_data, sizeof(slot->tx_data),
|
|
"{\"type\":\"writer\",\"event\":\"sync\",\"sequence\":0,"
|
|
"\"clientId\":%" PRIu32 ",\"role\":\"%s\",\"writerId\":%" PRIu32 "}",
|
|
client_id, writer ? "writer" : "observer", writer_id);
|
|
if (written <= 0 || (size_t)written >= sizeof(slot->tx_data)) {
|
|
add_counter(&s_counters.protocol_errors, 1U);
|
|
return false;
|
|
}
|
|
return queue_slot_frame(slot, generation, HTTPD_WS_TYPE_TEXT,
|
|
(size_t)written) == ESP_OK;
|
|
}
|
|
|
|
static void drain_binary_output(web_serial_slot_t *slot, uint32_t generation,
|
|
session_broker_client_id_t client_id)
|
|
{
|
|
size_t received = 0U;
|
|
esp_err_t result = session_broker_read(client_id, slot->tx_data,
|
|
sizeof(slot->tx_data), &received);
|
|
if (result == ESP_OK && received > 0U) {
|
|
(void)queue_slot_frame(slot, generation, HTTPD_WS_TYPE_BINARY, received);
|
|
} else if (result == ESP_ERR_NOT_FOUND) {
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->generation == generation) {
|
|
slot->close_requested = true;
|
|
++s_counters.broker_failures;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
} else if (result != ESP_OK && result != ESP_ERR_TIMEOUT) {
|
|
add_counter(&s_counters.broker_failures, 1U);
|
|
}
|
|
}
|
|
|
|
static void process_close_request(web_serial_slot_t *slot)
|
|
{
|
|
httpd_handle_t server = NULL;
|
|
int socket_fd = -1;
|
|
uint32_t generation = 0U;
|
|
int64_t now_us = monotonic_time_us();
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->state == WEB_SERIAL_SLOT_ACTIVE && slot->close_requested &&
|
|
!slot->close_triggered && slot->close_retry_at_us <= now_us &&
|
|
slot->server != NULL && slot->server == s_server) {
|
|
slot->close_triggered = true;
|
|
server = slot->server;
|
|
socket_fd = slot->socket_fd;
|
|
generation = slot->generation;
|
|
++s_counters.close_requests;
|
|
++s_httpd_close_operations;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
|
|
if (server == NULL) {
|
|
return;
|
|
}
|
|
|
|
esp_err_t result = httpd_sess_trigger_close(server, socket_fd);
|
|
bool session_gone = false;
|
|
if (result != ESP_OK) {
|
|
session_gone = result == ESP_ERR_NOT_FOUND ||
|
|
httpd_sess_get_ctx(server, socket_fd) != slot;
|
|
}
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (result != ESP_OK && slot->generation == generation &&
|
|
slot->state == WEB_SERIAL_SLOT_ACTIVE) {
|
|
++s_counters.queue_failures;
|
|
if (session_gone) {
|
|
slot->state = WEB_SERIAL_SLOT_CLOSING;
|
|
slot->writer = false;
|
|
slot->hello_pending = false;
|
|
slot->work_pending = false;
|
|
slot->tx_length = 0U;
|
|
++s_counters.disconnections;
|
|
} else {
|
|
/* The nonblocking HTTPD control queue was full; retry later. */
|
|
slot->close_triggered = false;
|
|
slot->close_retry_at_us = now_us + 100000LL;
|
|
}
|
|
}
|
|
if (s_httpd_close_operations > 0U) {
|
|
--s_httpd_close_operations;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
notify_transport_task();
|
|
}
|
|
|
|
static void process_broker_disconnect(web_serial_slot_t *slot)
|
|
{
|
|
session_broker_client_id_t client_id = SESSION_BROKER_NO_CLIENT;
|
|
uint32_t generation = 0U;
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->state == WEB_SERIAL_SLOT_CLOSING && !slot->disconnect_busy &&
|
|
slot->broker_client_id != SESSION_BROKER_NO_CLIENT) {
|
|
slot->disconnect_busy = true;
|
|
client_id = slot->broker_client_id;
|
|
generation = slot->generation;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
|
|
if (client_id == SESSION_BROKER_NO_CLIENT) {
|
|
return;
|
|
}
|
|
|
|
esp_err_t result = session_broker_disconnect(client_id);
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->state == WEB_SERIAL_SLOT_CLOSING &&
|
|
slot->generation == generation && slot->broker_client_id == client_id) {
|
|
slot->disconnect_busy = false;
|
|
if (result == ESP_OK || result == ESP_ERR_NOT_FOUND) {
|
|
slot->broker_client_id = SESSION_BROKER_NO_CLIENT;
|
|
} else {
|
|
++s_counters.broker_failures;
|
|
}
|
|
finish_closing_slot_locked(slot);
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
}
|
|
|
|
static void process_active_output(web_serial_slot_t *slot)
|
|
{
|
|
uint32_t generation;
|
|
session_broker_client_id_t client_id;
|
|
bool active;
|
|
bool hello_pending;
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
active = slot->state == WEB_SERIAL_SLOT_ACTIVE &&
|
|
!slot->work_pending && !slot->close_requested &&
|
|
slot->server == s_server;
|
|
generation = slot->generation;
|
|
client_id = slot->broker_client_id;
|
|
hello_pending = slot->hello_pending;
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
if (!active || client_id == SESSION_BROKER_NO_CLIENT) {
|
|
return;
|
|
}
|
|
|
|
if (hello_pending) {
|
|
if (!prepare_hello(slot, generation, client_id)) {
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (slot->generation == generation &&
|
|
slot->state == WEB_SERIAL_SLOT_ACTIVE &&
|
|
!slot->work_pending) {
|
|
slot->close_requested = true;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
}
|
|
return;
|
|
}
|
|
if (prepare_writer_event(slot, generation, client_id)) {
|
|
return;
|
|
}
|
|
if (prepare_writer_sync(slot, generation, client_id)) {
|
|
return;
|
|
}
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
active = slot->state == WEB_SERIAL_SLOT_ACTIVE &&
|
|
slot->generation == generation && !slot->work_pending &&
|
|
!slot->close_requested;
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
if (active) {
|
|
drain_binary_output(slot, generation, client_id);
|
|
}
|
|
}
|
|
|
|
static void transport_task(void *context)
|
|
{
|
|
(void)context;
|
|
for (;;) {
|
|
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS;
|
|
++index) {
|
|
web_serial_slot_t *slot = &s_slots[index];
|
|
process_close_request(slot);
|
|
process_broker_disconnect(slot);
|
|
process_active_output(slot);
|
|
}
|
|
(void)ulTaskNotifyTake(pdTRUE,
|
|
milliseconds_to_ticks(WEB_SERIAL_POLL_MS));
|
|
}
|
|
}
|
|
|
|
esp_err_t web_serial_transport_init(void)
|
|
{
|
|
#if defined(CONFIG_HTTPD_QUEUE_WORK_BLOCKING) && CONFIG_HTTPD_QUEUE_WORK_BLOCKING
|
|
return ESP_ERR_NOT_SUPPORTED;
|
|
#else
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (s_initialized || s_transport_task != NULL) {
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
memset(s_tickets, 0, sizeof(s_tickets));
|
|
memset(s_slots, 0, sizeof(s_slots));
|
|
memset(&s_counters, 0, sizeof(s_counters));
|
|
s_server = NULL;
|
|
s_httpd_close_operations = 0U;
|
|
s_inflight_handlers = 0U;
|
|
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++index) {
|
|
s_slots[index].socket_fd = -1;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
|
|
TaskHandle_t task = xTaskCreateStatic(
|
|
transport_task, "web_serial", WEB_SERIAL_TASK_STACK_SIZE, NULL,
|
|
WEB_SERIAL_TASK_PRIORITY, s_transport_task_stack,
|
|
&s_transport_task_storage);
|
|
if (task == NULL) {
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
s_transport_task = task;
|
|
s_initialized = true;
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
return ESP_OK;
|
|
#endif
|
|
}
|
|
|
|
esp_err_t web_serial_transport_attach_server(httpd_handle_t server)
|
|
{
|
|
if (server == NULL) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
esp_err_t result = ESP_OK;
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (!s_initialized) {
|
|
result = ESP_ERR_INVALID_STATE;
|
|
} else if (s_server != NULL) {
|
|
result = ESP_ERR_INVALID_STATE;
|
|
} else {
|
|
clear_all_tickets_locked();
|
|
s_server = server;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
if (result == ESP_OK) {
|
|
notify_transport_task();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
esp_err_t web_serial_transport_detach_server(httpd_handle_t server)
|
|
{
|
|
if (server == NULL) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
esp_err_t result = ESP_OK;
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (!s_initialized || s_server != server) {
|
|
result = ESP_ERR_INVALID_STATE;
|
|
} else {
|
|
/*
|
|
* Revoke broker access before touching HTTPD. This keeps a failed server
|
|
* stop from retaining a writer lease or accepting post-detach input.
|
|
* Reserved generations remain owned by their in-progress handler, which
|
|
* observes the cleared server and disconnects unpublished broker state.
|
|
*/
|
|
s_server = NULL;
|
|
clear_all_tickets_locked();
|
|
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS;
|
|
++index) {
|
|
web_serial_slot_t *slot = &s_slots[index];
|
|
if (slot->server != server) {
|
|
continue;
|
|
}
|
|
/* Queued work validates this flag before touching the socket. */
|
|
slot->work_pending = false;
|
|
slot->tx_length = 0U;
|
|
if (slot->state == WEB_SERIAL_SLOT_ACTIVE) {
|
|
slot->state = WEB_SERIAL_SLOT_CLOSING;
|
|
slot->writer = false;
|
|
slot->hello_pending = false;
|
|
slot->close_requested = false;
|
|
slot->close_triggered = true;
|
|
slot->close_retry_at_us = 0;
|
|
++s_counters.disconnections;
|
|
}
|
|
}
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
if (result == ESP_OK) {
|
|
notify_transport_task();
|
|
/*
|
|
* No admitted handler or broker writer may outlive detach. HTTPD sends
|
|
* can finish later, but their static buffers cannot be reused until the
|
|
* server itself has stopped.
|
|
*/
|
|
int64_t detach_deadline =
|
|
monotonic_time_us() + WEB_SERIAL_DETACH_TIMEOUT_US;
|
|
for (;;) {
|
|
bool broker_sessions_gone = true;
|
|
taskENTER_CRITICAL(&s_lock);
|
|
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS;
|
|
++index) {
|
|
const web_serial_slot_t *slot = &s_slots[index];
|
|
if (slot->server == server &&
|
|
slot->broker_client_id != SESSION_BROKER_NO_CLIENT) {
|
|
broker_sessions_gone = false;
|
|
break;
|
|
}
|
|
}
|
|
bool operations_done = s_httpd_close_operations == 0U &&
|
|
s_inflight_handlers == 0U;
|
|
bool quiescent = operations_done && broker_sessions_gone;
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
if (quiescent) {
|
|
break;
|
|
}
|
|
if (operations_done && monotonic_time_us() >= detach_deadline) {
|
|
session_broker_client_id_t writer_id =
|
|
session_broker_get_writer_id();
|
|
bool web_writer = false;
|
|
taskENTER_CRITICAL(&s_lock);
|
|
for (size_t index = 0U;
|
|
index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++index) {
|
|
const web_serial_slot_t *slot = &s_slots[index];
|
|
if (slot->server == server &&
|
|
slot->broker_client_id == writer_id) {
|
|
web_writer = writer_id != SESSION_BROKER_NO_CLIENT;
|
|
break;
|
|
}
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
if (web_writer) {
|
|
(void)session_broker_force_writer(SESSION_BROKER_NO_CLIENT);
|
|
}
|
|
result = ESP_ERR_TIMEOUT;
|
|
break;
|
|
}
|
|
notify_transport_task();
|
|
vTaskDelay(milliseconds_to_ticks(1U));
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
esp_err_t web_serial_transport_mint_ticket(char *ticket, size_t capacity)
|
|
{
|
|
if (ticket == NULL || capacity < WEB_SERIAL_TRANSPORT_TICKET_CAPACITY) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
ticket[0] = '\0';
|
|
|
|
uint8_t random_bytes[WEB_SERIAL_RANDOM_BYTES] = {0};
|
|
uint8_t digest[WEB_SERIAL_SHA256_BYTES] = {0};
|
|
uint32_t generation = 0U;
|
|
esp_err_t result = current_material_generation(&generation);
|
|
if (result == ESP_OK) {
|
|
result = secure_random_fill(random_bytes, sizeof(random_bytes));
|
|
}
|
|
if (result == ESP_OK) {
|
|
encode_base64url_24(random_bytes, ticket);
|
|
result = sha256_ticket(ticket, digest);
|
|
}
|
|
if (result != ESP_OK) {
|
|
ticket[0] = '\0';
|
|
goto cleanup;
|
|
}
|
|
|
|
int64_t now_us = monotonic_time_us();
|
|
bool stored = false;
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (s_initialized && s_server != NULL) {
|
|
purge_tickets_locked(now_us, generation, true);
|
|
size_t selected = WEB_SERIAL_TRANSPORT_MAX_TICKETS;
|
|
int64_t oldest_expiry = INT64_MAX;
|
|
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS;
|
|
++index) {
|
|
web_serial_ticket_t *entry = &s_tickets[index];
|
|
if (!entry->active) {
|
|
selected = index;
|
|
break;
|
|
}
|
|
if (entry->expires_at_us < oldest_expiry) {
|
|
oldest_expiry = entry->expires_at_us;
|
|
selected = index;
|
|
}
|
|
}
|
|
if (selected < WEB_SERIAL_TRANSPORT_MAX_TICKETS) {
|
|
web_serial_ticket_t *entry = &s_tickets[selected];
|
|
clear_ticket_locked(entry);
|
|
memcpy(entry->digest, digest, sizeof(entry->digest));
|
|
entry->expires_at_us =
|
|
now_us + (int64_t)WEB_SERIAL_TRANSPORT_TICKET_LIFETIME_SECONDS *
|
|
1000000LL;
|
|
entry->material_generation = generation;
|
|
entry->active = true;
|
|
++s_counters.tickets_issued;
|
|
stored = true;
|
|
}
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
|
|
if (!stored) {
|
|
ticket[0] = '\0';
|
|
result = ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
cleanup:
|
|
secure_wipe(random_bytes, sizeof(random_bytes));
|
|
secure_wipe(digest, sizeof(digest));
|
|
return result;
|
|
}
|
|
|
|
esp_err_t web_serial_transport_handle_authenticated_ticket_request(
|
|
httpd_req_t *request)
|
|
{
|
|
if (request == NULL) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
if (request->method != HTTP_POST || request->content_len != 0U ||
|
|
validate_origin(request) != ESP_OK) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
bool attached_request;
|
|
taskENTER_CRITICAL(&s_lock);
|
|
attached_request = s_initialized && s_server == request->handle;
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
if (!attached_request) {
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
|
|
char ticket[WEB_SERIAL_TRANSPORT_TICKET_CAPACITY] = {0};
|
|
char response[WEB_SERIAL_TICKET_RESPONSE_CAPACITY];
|
|
esp_err_t result = web_serial_transport_mint_ticket(ticket, sizeof(ticket));
|
|
if (result != ESP_OK) {
|
|
secure_wipe(ticket, sizeof(ticket));
|
|
return result;
|
|
}
|
|
|
|
int written = snprintf(
|
|
response, sizeof(response),
|
|
"{\"ticket\":\"%s\",\"expiresIn\":%u}\n", ticket,
|
|
(unsigned int)WEB_SERIAL_TRANSPORT_TICKET_LIFETIME_SECONDS);
|
|
if (written <= 0 || (size_t)written >= sizeof(response)) {
|
|
secure_wipe(ticket, sizeof(ticket));
|
|
secure_wipe(response, sizeof(response));
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
result = httpd_resp_set_type(request, "application/json; charset=utf-8");
|
|
if (result == ESP_OK) {
|
|
result = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
|
}
|
|
if (result == ESP_OK) {
|
|
result = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
|
}
|
|
if (result == ESP_OK) {
|
|
result = httpd_resp_send(request, response, written);
|
|
}
|
|
secure_wipe(ticket, sizeof(ticket));
|
|
secure_wipe(response, sizeof(response));
|
|
return result;
|
|
}
|
|
|
|
esp_err_t web_serial_transport_ws_handler(httpd_req_t *request)
|
|
{
|
|
if (request == NULL || request->handle == NULL) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
int socket_fd = httpd_req_to_sockfd(request);
|
|
if (socket_fd < 0) {
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
httpd_ws_client_info_t info =
|
|
httpd_ws_get_fd_info(request->handle, socket_fd);
|
|
if (info == HTTPD_WS_CLIENT_HTTP) {
|
|
(void)send_plain_bad_request(request);
|
|
add_counter(&s_counters.protocol_errors, 1U);
|
|
return ESP_FAIL;
|
|
}
|
|
if (info != HTTPD_WS_CLIENT_WEBSOCKET) {
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
bool admitted = s_initialized && s_server == request->handle;
|
|
if (admitted) {
|
|
++s_inflight_handlers;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
if (!admitted) {
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
esp_err_t result;
|
|
if (request->sess_ctx == NULL) {
|
|
/* IDF has already sent 101; authentication failures must only close. */
|
|
result = connect_websocket(request, socket_fd);
|
|
} else {
|
|
result = process_websocket_frame(request);
|
|
}
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (s_inflight_handlers > 0U) {
|
|
--s_inflight_handlers;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
return result;
|
|
}
|
|
|
|
esp_err_t web_serial_transport_get_snapshot(
|
|
web_serial_transport_snapshot_t *snapshot)
|
|
{
|
|
if (snapshot == NULL) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
uint32_t generation = 0U;
|
|
bool have_generation = current_material_generation(&generation) == ESP_OK;
|
|
int64_t now_us = monotonic_time_us();
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (!s_initialized) {
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
purge_tickets_locked(now_us, generation, have_generation);
|
|
memset(snapshot, 0, sizeof(*snapshot));
|
|
snapshot->initialized = true;
|
|
snapshot->server_attached = s_server != NULL;
|
|
snapshot->counters = s_counters;
|
|
|
|
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++index) {
|
|
if (s_tickets[index].active) {
|
|
++snapshot->active_tickets;
|
|
}
|
|
}
|
|
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++index) {
|
|
const web_serial_slot_t *slot = &s_slots[index];
|
|
web_serial_transport_session_snapshot_t *session =
|
|
&snapshot->sessions[index];
|
|
if (slot->state != WEB_SERIAL_SLOT_ACTIVE) {
|
|
session->socket_fd = -1;
|
|
continue;
|
|
}
|
|
session->active = true;
|
|
session->writer = slot->writer;
|
|
session->tx_pending = slot->work_pending;
|
|
session->close_requested = slot->close_requested;
|
|
session->socket_fd = slot->socket_fd;
|
|
session->generation = slot->generation;
|
|
session->broker_client_id = slot->broker_client_id;
|
|
++snapshot->active_sessions;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
return ESP_OK;
|
|
}
|
|
|
|
esp_err_t web_serial_transport_clear_counters(void)
|
|
{
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (!s_initialized) {
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
memset(&s_counters, 0, sizeof(s_counters));
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
return ESP_OK;
|
|
}
|
|
|
|
esp_err_t web_serial_transport_revoke_sessions(void)
|
|
{
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (!s_initialized) {
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
|
|
clear_all_tickets_locked();
|
|
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++index) {
|
|
web_serial_slot_t *slot = &s_slots[index];
|
|
if (slot->state == WEB_SERIAL_SLOT_ACTIVE) {
|
|
slot->close_requested = true;
|
|
}
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
notify_transport_task();
|
|
return ESP_OK;
|
|
}
|