From 94bb937a37655fccbc6d1c58d3d69903e4d786ab Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:06:23 -0400 Subject: [PATCH 01/48] feat: expose TF session metadata for web downloads --- main/bandit_storage.h | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/main/bandit_storage.h b/main/bandit_storage.h index 415d495..4fc4073 100644 --- a/main/bandit_storage.h +++ b/main/bandit_storage.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include "esp_err.h" @@ -12,10 +14,26 @@ typedef enum { BANDIT_STORAGE_ERROR, } bandit_storage_state_t; +typedef struct { + unsigned index; + size_t size_bytes; + bool active; +} bandit_storage_session_t; + esp_err_t bandit_storage_init(void); void bandit_storage_service(void); void bandit_storage_log_record(const bandit_scan_record_t *record, void *ctx); void bandit_storage_finish_scan(uint32_t generation); bandit_storage_state_t bandit_storage_get_state(void); const char *bandit_storage_get_session_path(void); +esp_err_t bandit_storage_list_sessions( + bandit_storage_session_t *sessions, + size_t capacity, + size_t *count +); +esp_err_t bandit_storage_session_path( + unsigned index, + char *path, + size_t path_size +); void bandit_storage_deinit(void); From 21af7055187190c9612e0d133c3d1303d5b64e5a Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:06:30 -0400 Subject: [PATCH 02/48] feat: list and resolve TF log sessions safely --- main/bandit_storage.c | 115 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/main/bandit_storage.c b/main/bandit_storage.c index 710b60c..b36e20a 100644 --- a/main/bandit_storage.c +++ b/main/bandit_storage.c @@ -1,7 +1,9 @@ #include "bandit_storage.h" +#include #include #include +#include #include #include #include @@ -317,6 +319,119 @@ const char *bandit_storage_get_session_path(void) return s_session_path; } + +static int compare_session_desc(const void *left, const void *right) +{ + const bandit_storage_session_t *a = left; + const bandit_storage_session_t *b = right; + + if (a->index < b->index) { + return 1; + } + if (a->index > b->index) { + return -1; + } + return 0; +} + +esp_err_t bandit_storage_session_path( + unsigned index, + char *path, + size_t path_size +) +{ + if (!path || path_size == 0 || index == 0 || index > BANDIT_SESSION_LIMIT) { + return ESP_ERR_INVALID_ARG; + } + + int written = snprintf( + path, + path_size, + BANDIT_LOG_DIR "/session-%04u.csv", + index + ); + + if (written <= 0 || written >= (int)path_size) { + return ESP_ERR_INVALID_SIZE; + } + + return ESP_OK; +} + +esp_err_t bandit_storage_list_sessions( + bandit_storage_session_t *sessions, + size_t capacity, + size_t *count +) +{ + if (!sessions || capacity == 0 || !count) { + return ESP_ERR_INVALID_ARG; + } + + *count = 0; + + if (!s_mounted || s_state != BANDIT_STORAGE_READY) { + return ESP_ERR_INVALID_STATE; + } + + DIR *dir = opendir(BANDIT_LOG_DIR); + if (!dir) { + ESP_LOGW(TAG, "opendir %s failed: %s", BANDIT_LOG_DIR, strerror(errno)); + return ESP_FAIL; + } + + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) { + unsigned index = 0; + int consumed = 0; + + if (sscanf(entry->d_name, "session-%4u.csv%n", &index, &consumed) != 1 || + entry->d_name[consumed] != '\0' || + index == 0 || + index > BANDIT_SESSION_LIMIT) { + continue; + } + + char path[BANDIT_SESSION_PATH_MAX]; + if (bandit_storage_session_path(index, path, sizeof(path)) != ESP_OK) { + continue; + } + + struct stat st; + if (stat(path, &st) != 0) { + continue; + } + + bandit_storage_session_t candidate = { + .index = index, + .size_bytes = (size_t)st.st_size, + .active = strcmp(path, s_session_path) == 0, + }; + + if (*count < capacity) { + sessions[(*count)++] = candidate; + continue; + } + + // Capacity is intentionally bounded for the web UI. Retain the newest + // session numbers if the card contains more files than fit in one page. + size_t oldest = 0; + for (size_t i = 1; i < capacity; i++) { + if (sessions[i].index < sessions[oldest].index) { + oldest = i; + } + } + + if (candidate.index > sessions[oldest].index) { + sessions[oldest] = candidate; + } + } + + closedir(dir); + qsort(sessions, *count, sizeof(*sessions), compare_session_desc); + return ESP_OK; +} + void bandit_storage_deinit(void) { if (s_log_file) { From 5e03836693ca869b24bcb18f16326685d9dc2ed1 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:07:30 -0400 Subject: [PATCH 03/48] feat: expose latest-scan AP cache hooks --- main/bandit_uplink.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main/bandit_uplink.h b/main/bandit_uplink.h index aeac38c..83905c1 100644 --- a/main/bandit_uplink.h +++ b/main/bandit_uplink.h @@ -9,6 +9,8 @@ esp_err_t bandit_uplink_init(void); void bandit_uplink_service(void); void bandit_uplink_stop(void); +void bandit_uplink_begin_scan(void); +void bandit_uplink_observe_record(const bandit_scan_record_t *record); void bandit_uplink_publish_snapshot(const bandit_scan_snapshot_t *snapshot); bool bandit_uplink_get_configured_ssid(char out_ssid[33]); From d9131d0795ad6ebd325fcf3ae3bca4c03d8236ac Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:07:35 -0400 Subject: [PATCH 04/48] feat: fan scan records into storage and web cache --- main/app_main.c | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/main/app_main.c b/main/app_main.c index f82f5a4..ed0e591 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -72,6 +72,13 @@ static void wait_for_next_scan_or_ota(void) } } +static void handle_scan_record(const bandit_scan_record_t *record, void *ctx) +{ + (void)ctx; + bandit_storage_log_record(record, NULL); + bandit_uplink_observe_record(record); +} + static void scan_task(void *arg) { (void)arg; @@ -86,10 +93,11 @@ static void scan_task(void *arg) bandit_scan_snapshot_t snapshot; bandit_ui_set_status("SCANNING"); + bandit_uplink_begin_scan(); esp_err_t ret = bandit_scan_once( &snapshot, - bandit_storage_log_record, + handle_scan_record, NULL ); From 3df6ac19f14fe60191e1a9a80f7a3eb25492e2b3 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:08:29 -0400 Subject: [PATCH 05/48] feat: allocate bounded AP cache and web transfer limits --- main/bandit_uplink.c | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/main/bandit_uplink.c b/main/bandit_uplink.c index 0b18904..11b5d4d 100644 --- a/main/bandit_uplink.c +++ b/main/bandit_uplink.c @@ -1,5 +1,6 @@ #include "bandit_uplink.h" +#include #include #include #include @@ -7,6 +8,7 @@ #include #include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" #include "freertos/task.h" #include "esp_event.h" @@ -27,6 +29,9 @@ #define BANDIT_UPLINK_NVS_PASSWORD "password" #define BANDIT_UPLINK_RETRY_MS 15000 #define BANDIT_UPLINK_HTTP_STACK 8192 +#define BANDIT_UPLINK_AP_CACHE_MAX 64 +#define BANDIT_UPLINK_SESSION_LIST_MAX 32 +#define BANDIT_UPLINK_DOWNLOAD_BUFFER 2048 static const char *TAG = "bandit_uplink"; @@ -46,6 +51,15 @@ static portMUX_TYPE s_state_mux = portMUX_INITIALIZER_UNLOCKED; static bandit_scan_snapshot_t s_snapshot; static bool s_have_snapshot; +static SemaphoreHandle_t s_ap_mutex; +static bandit_scan_record_t s_work_aps[BANDIT_UPLINK_AP_CACHE_MAX]; +static size_t s_work_ap_count; +static size_t s_work_seen; +static bandit_scan_record_t s_published_aps[BANDIT_UPLINK_AP_CACHE_MAX]; +static size_t s_published_ap_count; +static uint32_t s_published_generation; +static bool s_published_truncated; + static const char s_status_page[] = "" "" From 4908a85ccea8f6becb24fdfe34d274cd3d410707 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:09:03 -0400 Subject: [PATCH 06/48] feat: add AP table and TF log browser to status page --- main/bandit_uplink.c | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/main/bandit_uplink.c b/main/bandit_uplink.c index 11b5d4d..5fe41e4 100644 --- a/main/bandit_uplink.c +++ b/main/bandit_uplink.c @@ -67,10 +67,17 @@ static const char s_status_page[] = "C5 Bandit" "" "

C5 Bandit

loading...
" "
Uplink
loading...
" @@ -80,10 +87,15 @@ static const char s_status_page[] = "" "
Strongest
--
" "
waiting for scan...
" + "
Latest scan APs
waiting for scan...
" + "
" + "
SSIDBSSIDBandCHRSSIAuth
" + "
TF sessions
Newest 32 sessions; active log may lag by the flush interval.
" + "
loading...
" ""; static void json_escape(const char *src, char *dst, size_t dst_size) From 94ec85340d5bfde68f85e21b4d686d0229afbe77 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:09:52 -0400 Subject: [PATCH 07/48] feat: serve AP cache and downloadable TF sessions --- main/bandit_uplink.c | 278 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) diff --git a/main/bandit_uplink.c b/main/bandit_uplink.c index 5fe41e4..bccd5fd 100644 --- a/main/bandit_uplink.c +++ b/main/bandit_uplink.c @@ -144,6 +144,20 @@ static void json_escape(const char *src, char *dst, size_t dst_size) dst[out] = '\0'; } +static int compare_ap_rssi_desc(const void *left, const void *right) +{ + const bandit_scan_record_t *a = left; + const bandit_scan_record_t *b = right; + + if (a->rssi > b->rssi) { + return -1; + } + if (a->rssi < b->rssi) { + return 1; + } + return strcmp(a->ssid, b->ssid); +} + static const char *storage_state_name(void) { switch (bandit_storage_get_state()) { @@ -245,6 +259,270 @@ static esp_err_t status_json_handler(httpd_req_t *req) return ret; } +static esp_err_t aps_json_handler(httpd_req_t *req) +{ + if (!s_ap_mutex) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "AP cache unavailable"); + return ESP_ERR_INVALID_STATE; + } + + bandit_scan_record_t *records = malloc( + BANDIT_UPLINK_AP_CACHE_MAX * sizeof(*records) + ); + if (!records) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory"); + return ESP_ERR_NO_MEM; + } + + size_t count = 0; + uint32_t generation = 0; + bool truncated = false; + + if (xSemaphoreTake(s_ap_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) { + free(records); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "AP cache busy"); + return ESP_ERR_TIMEOUT; + } + + count = s_published_ap_count; + generation = s_published_generation; + truncated = s_published_truncated; + memcpy(records, s_published_aps, count * sizeof(*records)); + xSemaphoreGive(s_ap_mutex); + + httpd_resp_set_type(req, "application/json; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + + char chunk[384]; + int written = snprintf( + chunk, + sizeof(chunk), + "{\"scan\":%lu,\"truncated\":%s,\"aps\":[", + (unsigned long)generation, + truncated ? "true" : "false" + ); + if (written < 0 || written >= (int)sizeof(chunk)) { + free(records); + return ESP_FAIL; + } + + esp_err_t ret = httpd_resp_send_chunk(req, chunk, written); + for (size_t i = 0; ret == ESP_OK && i < count; i++) { + const bandit_scan_record_t *record = &records[i]; + char escaped_ssid[70]; + char escaped_auth[30]; + char bssid[18]; + + json_escape(record->ssid, escaped_ssid, sizeof(escaped_ssid)); + json_escape(record->auth, escaped_auth, sizeof(escaped_auth)); + snprintf( + bssid, + sizeof(bssid), + "%02X:%02X:%02X:%02X:%02X:%02X", + record->bssid[0], + record->bssid[1], + record->bssid[2], + record->bssid[3], + record->bssid[4], + record->bssid[5] + ); + + written = snprintf( + chunk, + sizeof(chunk), + "%s{\"ssid\":\"%s\",\"bssid\":\"%s\",\"rssi\":%d," + "\"channel\":%u,\"band\":\"%s\",\"auth\":\"%s\",\"hidden\":%s}", + i ? "," : "", + escaped_ssid, + bssid, + record->rssi, + record->channel, + record->channel > 14 ? "5 GHz" : "2.4 GHz", + escaped_auth, + record->hidden ? "true" : "false" + ); + + if (written < 0 || written >= (int)sizeof(chunk)) { + ret = ESP_FAIL; + break; + } + + ret = httpd_resp_send_chunk(req, chunk, written); + } + + if (ret == ESP_OK) { + ret = httpd_resp_send_chunk(req, "]}", 2); + } + if (ret == ESP_OK) { + ret = httpd_resp_send_chunk(req, NULL, 0); + } + + free(records); + return ret; +} + +static esp_err_t logs_json_handler(httpd_req_t *req) +{ + bandit_storage_session_t *sessions = calloc( + BANDIT_UPLINK_SESSION_LIST_MAX, + sizeof(*sessions) + ); + if (!sessions) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory"); + return ESP_ERR_NO_MEM; + } + + size_t count = 0; + esp_err_t list_ret = bandit_storage_list_sessions( + sessions, + BANDIT_UPLINK_SESSION_LIST_MAX, + &count + ); + + httpd_resp_set_type(req, "application/json; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + + if (list_ret != ESP_OK) { + free(sessions); + return httpd_resp_sendstr( + req, + "{\"available\":false,\"sessions\":[]}" + ); + } + + esp_err_t ret = httpd_resp_send_chunk( + req, + "{\"available\":true,\"sessions\":[", + HTTPD_RESP_USE_STRLEN + ); + + char chunk[192]; + for (size_t i = 0; ret == ESP_OK && i < count; i++) { + int written = snprintf( + chunk, + sizeof(chunk), + "%s{\"index\":%u,\"name\":\"session-%04u.csv\"," + "\"bytes\":%zu,\"active\":%s}", + i ? "," : "", + sessions[i].index, + sessions[i].index, + sessions[i].size_bytes, + sessions[i].active ? "true" : "false" + ); + + if (written < 0 || written >= (int)sizeof(chunk)) { + ret = ESP_FAIL; + break; + } + + ret = httpd_resp_send_chunk(req, chunk, written); + } + + if (ret == ESP_OK) { + ret = httpd_resp_send_chunk(req, "]}", 2); + } + if (ret == ESP_OK) { + ret = httpd_resp_send_chunk(req, NULL, 0); + } + + free(sessions); + return ret; +} + +static esp_err_t download_handler(httpd_req_t *req) +{ + size_t query_len = httpd_req_get_url_query_len(req); + if (query_len == 0 || query_len >= 64) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing session index"); + return ESP_ERR_INVALID_ARG; + } + + char query[64]; + char value[16]; + if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK || + httpd_query_key_value(query, "session", value, sizeof(value)) != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid session query"); + return ESP_ERR_INVALID_ARG; + } + + errno = 0; + char *end = NULL; + unsigned long parsed = strtoul(value, &end, 10); + if (errno != 0 || !end || *end != '\0' || parsed == 0 || parsed > 9999) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid session index"); + return ESP_ERR_INVALID_ARG; + } + + char path[96]; + esp_err_t ret = bandit_storage_session_path( + (unsigned)parsed, + path, + sizeof(path) + ); + if (ret != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid session path"); + return ret; + } + + FILE *file = fopen(path, "rb"); + if (!file) { + httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Session not found"); + return ESP_ERR_NOT_FOUND; + } + + char disposition[64]; + snprintf( + disposition, + sizeof(disposition), + "attachment; filename=\"session-%04lu.csv\"", + parsed + ); + + httpd_resp_set_type(req, "text/csv; charset=utf-8"); + httpd_resp_set_hdr(req, "Content-Disposition", disposition); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + + char *buffer = malloc(BANDIT_UPLINK_DOWNLOAD_BUFFER); + if (!buffer) { + fclose(file); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory"); + return ESP_ERR_NO_MEM; + } + + while (1) { + size_t read_count = fread( + buffer, + 1, + BANDIT_UPLINK_DOWNLOAD_BUFFER, + file + ); + + if (read_count > 0) { + ret = httpd_resp_send_chunk(req, buffer, read_count); + if (ret != ESP_OK) { + break; + } + } + + if (read_count < BANDIT_UPLINK_DOWNLOAD_BUFFER) { + if (ferror(file)) { + ESP_LOGW(TAG, "read failed while downloading %s", path); + ret = ESP_FAIL; + } + break; + } + } + + free(buffer); + fclose(file); + + if (ret == ESP_OK) { + ret = httpd_resp_send_chunk(req, NULL, 0); + } + + return ret; +} + static esp_err_t start_status_server(void) { if (s_server) { From ce6de2606ce47d4a2895cdb47fcc9c7ba6ab3dbb Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:10:32 -0400 Subject: [PATCH 08/48] feat: publish coherent AP cache and register observability routes --- main/bandit_uplink.c | 86 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/main/bandit_uplink.c b/main/bandit_uplink.c index bccd5fd..c1c7022 100644 --- a/main/bandit_uplink.c +++ b/main/bandit_uplink.c @@ -531,7 +531,7 @@ static esp_err_t start_status_server(void) httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.stack_size = BANDIT_UPLINK_HTTP_STACK; - config.max_uri_handlers = 3; + config.max_uri_handlers = 5; esp_err_t ret = httpd_start(&s_server, &config); if (ret != ESP_OK) { @@ -550,11 +550,35 @@ static esp_err_t start_status_server(void) .method = HTTP_GET, .handler = status_json_handler, }; + httpd_uri_t aps = { + .uri = "/aps.json", + .method = HTTP_GET, + .handler = aps_json_handler, + }; + httpd_uri_t logs = { + .uri = "/logs.json", + .method = HTTP_GET, + .handler = logs_json_handler, + }; + httpd_uri_t download = { + .uri = "/download", + .method = HTTP_GET, + .handler = download_handler, + }; ret = httpd_register_uri_handler(s_server, &root); if (ret == ESP_OK) { ret = httpd_register_uri_handler(s_server, &status); } + if (ret == ESP_OK) { + ret = httpd_register_uri_handler(s_server, &aps); + } + if (ret == ESP_OK) { + ret = httpd_register_uri_handler(s_server, &logs); + } + if (ret == ESP_OK) { + ret = httpd_register_uri_handler(s_server, &download); + } if (ret != ESP_OK) { ESP_LOGE(TAG, "status route registration failed: %s", esp_err_to_name(ret)); @@ -727,6 +751,13 @@ esp_err_t bandit_uplink_clear_credentials(void) esp_err_t bandit_uplink_init(void) { + if (!s_ap_mutex) { + s_ap_mutex = xSemaphoreCreateMutex(); + if (!s_ap_mutex) { + return ESP_ERR_NO_MEM; + } + } + char password[64] = {0}; char ssid[33] = {0}; @@ -873,12 +904,65 @@ void bandit_uplink_stop(void) portEXIT_CRITICAL(&s_state_mux); } +void bandit_uplink_begin_scan(void) +{ + s_work_ap_count = 0; + s_work_seen = 0; +} + +void bandit_uplink_observe_record(const bandit_scan_record_t *record) +{ + if (!record) { + return; + } + + s_work_seen++; + + if (s_work_ap_count < BANDIT_UPLINK_AP_CACHE_MAX) { + s_work_aps[s_work_ap_count++] = *record; + return; + } + + // Keep the strongest bounded set if a dense environment exceeds the + // browser cache capacity. + size_t weakest = 0; + for (size_t i = 1; i < s_work_ap_count; i++) { + if (s_work_aps[i].rssi < s_work_aps[weakest].rssi) { + weakest = i; + } + } + + if (record->rssi > s_work_aps[weakest].rssi) { + s_work_aps[weakest] = *record; + } +} + void bandit_uplink_publish_snapshot(const bandit_scan_snapshot_t *snapshot) { if (!snapshot) { return; } + qsort( + s_work_aps, + s_work_ap_count, + sizeof(s_work_aps[0]), + compare_ap_rssi_desc + ); + + if (s_ap_mutex && + xSemaphoreTake(s_ap_mutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + s_published_ap_count = s_work_ap_count; + s_published_generation = snapshot->generation; + s_published_truncated = s_work_seen > s_work_ap_count; + memcpy( + s_published_aps, + s_work_aps, + s_work_ap_count * sizeof(s_work_aps[0]) + ); + xSemaphoreGive(s_ap_mutex); + } + portENTER_CRITICAL(&s_state_mux); s_snapshot = *snapshot; s_have_snapshot = true; From 65148a6dea07ec8e8bb66ea0fdc10ee6e4079887 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:10:59 -0400 Subject: [PATCH 09/48] feat: identify v0.4 web observability build --- main/bandit_version.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main/bandit_version.h b/main/bandit_version.h index 711083e..1f80af2 100644 --- a/main/bandit_version.h +++ b/main/bandit_version.h @@ -1,4 +1,4 @@ #pragma once -#define BANDIT_VERSION "v0.3.1 UPLINK UI" -#define BANDIT_VERSION_SHORT "v0.3.1" +#define BANDIT_VERSION "v0.4.0 WEB OBS" +#define BANDIT_VERSION_SHORT "v0.4.0" From 991b1cd62d9c00890ae5055181e216bb730af8a5 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:11:03 -0400 Subject: [PATCH 10/48] docs: document v0.4 AP table and log downloads --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/README.md b/README.md index 60498f5..cb4dd3f 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,53 @@ Some hotspot implementations isolate connected clients from the hotspot host or The ESP32-C5 has one Wi-Fi radio. Active dual-band scouting temporarily visits channels away from the associated AP. BandiC5 explicitly uses Espressif's home-channel dwell between scan channels to help preserve the infrastructure connection, but brief latency during scans is expected. Uplink monitoring is intentionally low-bandwidth and read-only. +## v0.4 web observability + +v0.4 turns the existing uplink page into a practical read-only RF and logging console. + +### Latest-scan AP table + +The browser shows a table from the most recently **completed** RF scan: + +- SSID +- BSSID +- 2.4 GHz / 5 GHz band +- channel +- RSSI +- authentication mode + +Rows are sorted strongest-first. + +The device keeps a bounded cache of the strongest 64 APs. If a scan sees more than 64 APs, the JSON response marks the table as truncated rather than consuming unbounded RAM. The browser never reads a half-built scan; the working set is published only when a complete scan succeeds. + +The raw endpoint is: + +`/aps.json` + +SSID values are inserted into the browser through DOM `textContent`, not interpreted as HTML. + +### TF session browser and downloads + +When a TF card is mounted, the web page lists the newest 32 numbered BandiC5 sessions and provides a download link for each. + +Downloads use a numeric session parameter: + +`/download?session=17` + +The firmware reconstructs the expected `session-0017.csv` path internally. Arbitrary filesystem paths are never accepted from the browser. + +The current active session is marked separately. It is downloadable, but because BandiC5 intentionally batches TF flushes, the downloaded active file may lag the live scan by up to the normal flush interval. Completed session files are stable. + +Session metadata is available from: + +`/logs.json` + +CSV files are streamed in small chunks rather than loaded into RAM. + +### v0.3.1 recovery landmark + +The branch `release/v0.3.1` points at the exact physically validated v0.3.1 mainline commit. The connected GitHub tooling used for this project can mutate branches/PRs but does not expose Git tag/release-asset creation, so this branch is the durable recovery pointer until a GitHub Release/tag is created separately. + ## Build The project targets **ESP-IDF v5.5.5** and **esp32c5**. From dfc2e714a02621d5c5e110e0d6a0a779e2799096 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:11:47 -0400 Subject: [PATCH 11/48] fix: download only completed TF sessions --- main/bandit_uplink.c | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/main/bandit_uplink.c b/main/bandit_uplink.c index c1c7022..bfea9f5 100644 --- a/main/bandit_uplink.c +++ b/main/bandit_uplink.c @@ -111,8 +111,8 @@ static const char s_status_page[] = "async function refreshLogs(){const box=$('logs');try{const r=await fetch('/logs.json',{cache:'no-store'});const d=await r.json();" "box.replaceChildren();if(!d.available||!d.sessions.length){const s=document.createElement('span');s.className='muted';" "s.textContent=d.available?'No sessions found.':'TF card unavailable.';box.appendChild(s);return;}" - "for(const x of d.sessions){const a=document.createElement('a');a.className='log'+(x.active?' active':'');" - "a.href='/download?session='+x.index;a.textContent=x.name+' \\u00b7 '+Math.max(1,Math.round(x.bytes/1024))+' KiB'+(x.active?' \\u00b7 active':'');" + "for(const x of d.sessions){const a=document.createElement(x.active?'span':'a');a.className='log'+(x.active?' active':'');" + "if(!x.active)a.href='/download?session='+x.index;a.textContent=x.name+' \\u00b7 '+Math.max(1,Math.round(x.bytes/1024))+' KiB'+(x.active?' \\u00b7 active':'');" "box.appendChild(a);}}catch(e){box.replaceChildren();const s=document.createElement('span');s.className='muted';" "s.textContent='Session list temporarily unavailable';box.appendChild(s);}}" "tick();refreshAps();refreshLogs();setInterval(tick,2000);setInterval(refreshAps,2000);setInterval(refreshLogs,10000);" @@ -464,6 +464,12 @@ static esp_err_t download_handler(httpd_req_t *req) return ret; } + const char *active_path = bandit_storage_get_session_path(); + if (active_path && active_path[0] && strcmp(path, active_path) == 0) { + httpd_resp_set_status(req, "409 Conflict"); + return httpd_resp_sendstr(req, "Active session is still being written"); + } + FILE *file = fopen(path, "rb"); if (!file) { httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Session not found"); From 97221fdc6c5ab728485092bfe499b180c5c081f4 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:12:03 -0400 Subject: [PATCH 12/48] docs: clarify completed-session download policy --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cb4dd3f..68cec6b 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ Downloads use a numeric session parameter: The firmware reconstructs the expected `session-0017.csv` path internally. Arbitrary filesystem paths are never accepted from the browser. -The current active session is marked separately. It is downloadable, but because BandiC5 intentionally batches TF flushes, the downloaded active file may lag the live scan by up to the normal flush interval. Completed session files are stable. +The current active session is marked separately and is intentionally not downloadable while it is still open for writing. Completed session files are stable and downloadable. Session metadata is available from: From a1c8c65131c7e5c9072605bdb36369e3d324ee6b Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:47:49 -0400 Subject: [PATCH 13/48] fix: send CSV downloads with explicit content length --- main/bandit_uplink.c | 109 +++++++++++++++++++++++++++++++------------ 1 file changed, 78 insertions(+), 31 deletions(-) diff --git a/main/bandit_uplink.c b/main/bandit_uplink.c index bfea9f5..9af62f2 100644 --- a/main/bandit_uplink.c +++ b/main/bandit_uplink.c @@ -6,6 +6,7 @@ #include #include #include +#include #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" @@ -429,6 +430,25 @@ static esp_err_t logs_json_handler(httpd_req_t *req) return ret; } +static esp_err_t send_all_raw( + httpd_req_t *req, + const char *data, + size_t length +) +{ + while (length > 0) { + int sent = httpd_send(req, data, length); + if (sent <= 0) { + return ESP_FAIL; + } + + data += sent; + length -= (size_t)sent; + } + + return ESP_OK; +} + static esp_err_t download_handler(httpd_req_t *req) { size_t query_len = httpd_req_get_url_query_len(req); @@ -470,62 +490,89 @@ static esp_err_t download_handler(httpd_req_t *req) return httpd_resp_sendstr(req, "Active session is still being written"); } + struct stat st; + if (stat(path, &st) != 0 || st.st_size < 0) { + httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Session not found"); + return ESP_ERR_NOT_FOUND; + } + FILE *file = fopen(path, "rb"); if (!file) { httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Session not found"); return ESP_ERR_NOT_FOUND; } - char disposition[64]; - snprintf( - disposition, - sizeof(disposition), - "attachment; filename=\"session-%04lu.csv\"", + // ESP-IDF's chunked response helper necessarily emits + // Transfer-Encoding: chunked. Mobile download managers may report that + // as an unknown/-1 byte attachment. Completed CSVs already have a stable + // length, so frame the response explicitly with Content-Length while + // still streaming the file in small chunks from TF. + char header[384]; + int header_len = snprintf( + header, + sizeof(header), + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/csv; charset=utf-8\r\n" + "Content-Length: %lld\r\n" + "Content-Disposition: attachment; filename=\"session-%04lu.csv\"\r\n" + "Cache-Control: no-store\r\n" + "\r\n", + (long long)st.st_size, parsed ); - httpd_resp_set_type(req, "text/csv; charset=utf-8"); - httpd_resp_set_hdr(req, "Content-Disposition", disposition); - httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + if (header_len <= 0 || header_len >= (int)sizeof(header)) { + fclose(file); + return ESP_FAIL; + } + + ret = send_all_raw(req, header, (size_t)header_len); + if (ret != ESP_OK) { + fclose(file); + return ret; + } char *buffer = malloc(BANDIT_UPLINK_DOWNLOAD_BUFFER); if (!buffer) { fclose(file); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory"); return ESP_ERR_NO_MEM; } - while (1) { - size_t read_count = fread( - buffer, - 1, - BANDIT_UPLINK_DOWNLOAD_BUFFER, - file - ); + size_t total_sent = 0; + while (total_sent < (size_t)st.st_size) { + size_t remaining = (size_t)st.st_size - total_sent; + size_t request = remaining < BANDIT_UPLINK_DOWNLOAD_BUFFER + ? remaining + : BANDIT_UPLINK_DOWNLOAD_BUFFER; - if (read_count > 0) { - ret = httpd_resp_send_chunk(req, buffer, read_count); - if (ret != ESP_OK) { - break; - } + size_t read_count = fread(buffer, 1, request, file); + if (read_count == 0) { + ESP_LOGW(TAG, "short read while downloading %s", path); + ret = ESP_FAIL; + break; } - if (read_count < BANDIT_UPLINK_DOWNLOAD_BUFFER) { - if (ferror(file)) { - ESP_LOGW(TAG, "read failed while downloading %s", path); - ret = ESP_FAIL; - } + ret = send_all_raw(req, buffer, read_count); + if (ret != ESP_OK) { break; } - } - free(buffer); - fclose(file); + total_sent += read_count; + } - if (ret == ESP_OK) { - ret = httpd_resp_send_chunk(req, NULL, 0); + if (ret == ESP_OK && total_sent != (size_t)st.st_size) { + ESP_LOGW( + TAG, + "download size mismatch for %s: expected=%lld sent=%zu", + path, + (long long)st.st_size, + total_sent + ); + ret = ESP_FAIL; } + free(buffer); + fclose(file); return ret; } From 955e48f3912181502cf4c70a62ccd54dcdf59e6e Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:47:52 -0400 Subject: [PATCH 14/48] fix: identify v0.4 download framing build --- main/bandit_version.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main/bandit_version.h b/main/bandit_version.h index 1f80af2..bc9af28 100644 --- a/main/bandit_version.h +++ b/main/bandit_version.h @@ -1,4 +1,4 @@ #pragma once -#define BANDIT_VERSION "v0.4.0 WEB OBS" -#define BANDIT_VERSION_SHORT "v0.4.0" +#define BANDIT_VERSION "v0.4.1 WEB DL FIX" +#define BANDIT_VERSION_SHORT "v0.4.1" From b21bbb3bb0353ed5333e9755b72b61e0be06c1ed Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:48:05 -0400 Subject: [PATCH 15/48] docs: note mobile CSV download framing fix --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 68cec6b..7cb499a 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,14 @@ Session metadata is available from: CSV files are streamed in small chunks rather than loaded into RAM. + +### Mobile CSV download framing + +v0.4.1 changes completed-session downloads from HTTP chunked transfer to a fixed +`Content-Length` response. The file is still streamed from TF in 2 KiB pieces, +but mobile browsers/download managers can now display the real attachment size +instead of `-1 byte` / unknown length. + ### v0.3.1 recovery landmark The branch `release/v0.3.1` points at the exact physically validated v0.3.1 mainline commit. The connected GitHub tooling used for this project can mutate branches/PRs but does not expose Git tag/release-asset creation, so this branch is the durable recovery pointer until a GitHub Release/tag is created separately. From aab248e7dddd871b02d95a5e324ba7416c98ee0b Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:04:45 -0400 Subject: [PATCH 16/48] fix: serialize RF scans and CSV downloads --- main/bandit_uplink.h | 1 + 1 file changed, 1 insertion(+) diff --git a/main/bandit_uplink.h b/main/bandit_uplink.h index 83905c1..121cd32 100644 --- a/main/bandit_uplink.h +++ b/main/bandit_uplink.h @@ -10,6 +10,7 @@ esp_err_t bandit_uplink_init(void); void bandit_uplink_service(void); void bandit_uplink_stop(void); void bandit_uplink_begin_scan(void); +void bandit_uplink_end_scan(void); void bandit_uplink_observe_record(const bandit_scan_record_t *record); void bandit_uplink_publish_snapshot(const bandit_scan_snapshot_t *snapshot); From 47a2df16d6097ce48fc6d27b02e9bc4782c56a9b Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:04:47 -0400 Subject: [PATCH 17/48] fix: always release uplink radio guard after scan --- main/app_main.c | 1 + 1 file changed, 1 insertion(+) diff --git a/main/app_main.c b/main/app_main.c index ed0e591..3c04e97 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -100,6 +100,7 @@ static void scan_task(void *arg) handle_scan_record, NULL ); + bandit_uplink_end_scan(); if (ret == ESP_OK) { bandit_storage_finish_scan(snapshot.generation); From c64b707f5a00b0f661bb7bd16c46aca071b9dc28 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:05:43 -0400 Subject: [PATCH 18/48] fix: pause RF scans during CSV transfer --- main/bandit_uplink.c | 51 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/main/bandit_uplink.c b/main/bandit_uplink.c index 9af62f2..d54d73c 100644 --- a/main/bandit_uplink.c +++ b/main/bandit_uplink.c @@ -53,6 +53,7 @@ static bandit_scan_snapshot_t s_snapshot; static bool s_have_snapshot; static SemaphoreHandle_t s_ap_mutex; +static SemaphoreHandle_t s_radio_mutex; static bandit_scan_record_t s_work_aps[BANDIT_UPLINK_AP_CACHE_MAX]; static size_t s_work_ap_count; static size_t s_work_seen; @@ -103,12 +104,12 @@ static const char s_status_page[] = "$('strong').textContent=d.strongest+' \\u00b7 '+d.rssi+' dBm \\u00b7 CH '+d.channel;" "$('summary').textContent='TOTAL '+d.total+' \\u00b7 OPEN '+d.open+' \\u00b7 HIDDEN '+d.hidden+' \\u00b7 SCAN #'+d.scan;" "$('storage').textContent='Storage: '+d.storage+' \\u00b7 uptime '+Math.floor(d.uptime_ms/1000)+' s';" - "}catch(e){$('net').textContent='Status temporarily unavailable';}}" + "}catch(e){/* retain last-known status while HTTP server is busy */}}" "async function refreshAps(){try{const r=await fetch('/aps.json',{cache:'no-store'});const d=await r.json();" "const b=$('aps');b.replaceChildren();for(const a of d.aps){const tr=document.createElement('tr');" "cell(tr,a.ssid);cell(tr,a.bssid);cell(tr,a.band);cell(tr,a.channel);cell(tr,a.rssi+' dBm');cell(tr,a.auth);b.appendChild(tr);}" "$('apsmeta').textContent='Scan #'+d.scan+' \\u00b7 '+d.aps.length+' shown'+(d.truncated?' \\u00b7 strongest '+d.aps.length+' only':'');" - "}catch(e){$('apsmeta').textContent='AP table temporarily unavailable';}}" + "}catch(e){/* retain last complete AP table while HTTP server is busy */}}" "async function refreshLogs(){const box=$('logs');try{const r=await fetch('/logs.json',{cache:'no-store'});const d=await r.json();" "box.replaceChildren();if(!d.available||!d.sessions.length){const s=document.createElement('span');s.className='muted';" "s.textContent=d.available?'No sessions found.':'TF card unavailable.';box.appendChild(s);return;}" @@ -502,6 +503,23 @@ static esp_err_t download_handler(httpd_req_t *req) return ESP_ERR_NOT_FOUND; } + char *buffer = malloc(BANDIT_UPLINK_DOWNLOAD_BUFFER); + if (!buffer) { + fclose(file); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory"); + return ESP_ERR_NO_MEM; + } + + // Wait for any active RF sweep to finish, then hold the radio on the STA's + // home channel for the duration of the completed-session transfer. + if (!s_radio_mutex || + xSemaphoreTake(s_radio_mutex, pdMS_TO_TICKS(15000)) != pdTRUE) { + free(buffer); + fclose(file); + httpd_resp_set_status(req, "503 Service Unavailable"); + return httpd_resp_sendstr(req, "Radio busy; try the download again"); + } + // ESP-IDF's chunked response helper necessarily emits // Transfer-Encoding: chunked. Mobile download managers may report that // as an unknown/-1 byte attachment. Completed CSVs already have a stable @@ -522,22 +540,20 @@ static esp_err_t download_handler(httpd_req_t *req) ); if (header_len <= 0 || header_len >= (int)sizeof(header)) { + xSemaphoreGive(s_radio_mutex); + free(buffer); fclose(file); return ESP_FAIL; } ret = send_all_raw(req, header, (size_t)header_len); if (ret != ESP_OK) { + xSemaphoreGive(s_radio_mutex); + free(buffer); fclose(file); return ret; } - char *buffer = malloc(BANDIT_UPLINK_DOWNLOAD_BUFFER); - if (!buffer) { - fclose(file); - return ESP_ERR_NO_MEM; - } - size_t total_sent = 0; while (total_sent < (size_t)st.st_size) { size_t remaining = (size_t)st.st_size - total_sent; @@ -571,6 +587,7 @@ static esp_err_t download_handler(httpd_req_t *req) ret = ESP_FAIL; } + xSemaphoreGive(s_radio_mutex); free(buffer); fclose(file); return ret; @@ -811,6 +828,13 @@ esp_err_t bandit_uplink_init(void) } } + if (!s_radio_mutex) { + s_radio_mutex = xSemaphoreCreateMutex(); + if (!s_radio_mutex) { + return ESP_ERR_NO_MEM; + } + } + char password[64] = {0}; char ssid[33] = {0}; @@ -959,10 +983,21 @@ void bandit_uplink_stop(void) void bandit_uplink_begin_scan(void) { + if (s_radio_mutex) { + xSemaphoreTake(s_radio_mutex, portMAX_DELAY); + } + s_work_ap_count = 0; s_work_seen = 0; } +void bandit_uplink_end_scan(void) +{ + if (s_radio_mutex) { + xSemaphoreGive(s_radio_mutex); + } +} + void bandit_uplink_observe_record(const bandit_scan_record_t *record) { if (!record) { From 5d3ab6712e163dd2fff7f2c234ee68f0daae6d42 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:06:23 -0400 Subject: [PATCH 19/48] fix: identify radio-serialized download build --- main/bandit_version.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main/bandit_version.h b/main/bandit_version.h index bc9af28..dd6d513 100644 --- a/main/bandit_version.h +++ b/main/bandit_version.h @@ -1,4 +1,4 @@ #pragma once -#define BANDIT_VERSION "v0.4.1 WEB DL FIX" -#define BANDIT_VERSION_SHORT "v0.4.1" +#define BANDIT_VERSION "v0.4.2 WEB DL RADIO" +#define BANDIT_VERSION_SHORT "v0.4.2" From 9aece059ecf24150f24fe966af9a360d3711ad2e Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:06:27 -0400 Subject: [PATCH 20/48] docs: explain radio guard for CSV transfers --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 7cb499a..dc2d0c7 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,20 @@ v0.4.1 changes completed-session downloads from HTTP chunked transfer to a fixed but mobile browsers/download managers can now display the real attachment size instead of `-1 byte` / unknown length. + +### Single-radio CSV transfer guard + +v0.4.2 serializes active RF scans and completed-session downloads with a shared +radio mutex. A download waits for any current channel sweep to finish, then +temporarily keeps the single ESP32-C5 Wi-Fi radio on the associated AP's home +channel while streaming the CSV. RF scanning resumes automatically as soon as +the transfer ends. + +This addresses mobile transfers that received the correct `Content-Length` but +stalled at zero bytes when an active dual-band scan moved the STA off-channel. +The web UI also retains the last known status/table during brief server-busy +periods instead of replacing good data with a transient unavailable message. + ### v0.3.1 recovery landmark The branch `release/v0.3.1` points at the exact physically validated v0.3.1 mainline commit. The connected GitHub tooling used for this project can mutate branches/PRs but does not expose Git tag/release-asset creation, so this branch is the durable recovery pointer until a GitHub Release/tag is created separately. From 413996c202d06718db3cfbebf1a844d55261127e Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:20:26 -0400 Subject: [PATCH 21/48] fix: fetch CSVs in-page using supported chunked responses --- main/bandit_uplink.c | 121 +++++++++++++++++++------------------------ 1 file changed, 54 insertions(+), 67 deletions(-) diff --git a/main/bandit_uplink.c b/main/bandit_uplink.c index d54d73c..8cc93fc 100644 --- a/main/bandit_uplink.c +++ b/main/bandit_uplink.c @@ -78,7 +78,8 @@ static const char s_status_page[] = "th{color:#94a3b8;font-weight:600}td:first-child{max-width:18rem;overflow:hidden;text-overflow:ellipsis}" ".logs{display:flex;flex-wrap:wrap;gap:.55rem;margin-top:.7rem}" ".log{display:inline-block;padding:.55rem .7rem;border:1px solid #334155;border-radius:9px;" - "color:#93c5fd;text-decoration:none}.log.active{border-color:#34d399;color:#6ee7b7}" + "color:#93c5fd;text-decoration:none;background:transparent;font:inherit;cursor:pointer}.log:disabled{cursor:default;opacity:.9}" + ".log.active{border-color:#34d399;color:#6ee7b7}" "@media(max-width:520px){body{margin:1rem auto}.card{padding:.85rem}table{font-size:.82rem}}" "" "

C5 Bandit

loading...
" @@ -95,9 +96,9 @@ static const char s_status_page[] = "
TF sessions
Newest 32 sessions; active log may lag by the flush interval.
" "
loading...
" ""; @@ -431,25 +446,6 @@ static esp_err_t logs_json_handler(httpd_req_t *req) return ret; } -static esp_err_t send_all_raw( - httpd_req_t *req, - const char *data, - size_t length -) -{ - while (length > 0) { - int sent = httpd_send(req, data, length); - if (sent <= 0) { - return ESP_FAIL; - } - - data += sent; - length -= (size_t)sent; - } - - return ESP_OK; -} - static esp_err_t download_handler(httpd_req_t *req) { size_t query_len = httpd_req_get_url_query_len(req); @@ -510,8 +506,6 @@ static esp_err_t download_handler(httpd_req_t *req) return ESP_ERR_NO_MEM; } - // Wait for any active RF sweep to finish, then hold the radio on the STA's - // home channel for the duration of the completed-session transfer. if (!s_radio_mutex || xSemaphoreTake(s_radio_mutex, pdMS_TO_TICKS(15000)) != pdTRUE) { free(buffer); @@ -520,39 +514,25 @@ static esp_err_t download_handler(httpd_req_t *req) return httpd_resp_sendstr(req, "Radio busy; try the download again"); } - // ESP-IDF's chunked response helper necessarily emits - // Transfer-Encoding: chunked. Mobile download managers may report that - // as an unknown/-1 byte attachment. Completed CSVs already have a stable - // length, so frame the response explicitly with Content-Length while - // still streaming the file in small chunks from TF. - char header[384]; - int header_len = snprintf( - header, - sizeof(header), - "HTTP/1.1 200 OK\r\n" - "Content-Type: text/csv; charset=utf-8\r\n" - "Content-Length: %lld\r\n" - "Content-Disposition: attachment; filename=\"session-%04lu.csv\"\r\n" - "Cache-Control: no-store\r\n" - "\r\n", - (long long)st.st_size, + char disposition[64]; + char size_header[24]; + snprintf( + disposition, + sizeof(disposition), + "attachment; filename=\"session-%04lu.csv\"", parsed ); + snprintf( + size_header, + sizeof(size_header), + "%lld", + (long long)st.st_size + ); - if (header_len <= 0 || header_len >= (int)sizeof(header)) { - xSemaphoreGive(s_radio_mutex); - free(buffer); - fclose(file); - return ESP_FAIL; - } - - ret = send_all_raw(req, header, (size_t)header_len); - if (ret != ESP_OK) { - xSemaphoreGive(s_radio_mutex); - free(buffer); - fclose(file); - return ret; - } + httpd_resp_set_type(req, "text/csv; charset=utf-8"); + httpd_resp_set_hdr(req, "Content-Disposition", disposition); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + httpd_resp_set_hdr(req, "X-BandiC5-File-Size", size_header); size_t total_sent = 0; while (total_sent < (size_t)st.st_size) { @@ -568,28 +548,34 @@ static esp_err_t download_handler(httpd_req_t *req) break; } - ret = send_all_raw(req, buffer, read_count); + ret = httpd_resp_send_chunk(req, buffer, read_count); if (ret != ESP_OK) { + ESP_LOGW(TAG, "send failed while downloading %s", path); break; } total_sent += read_count; } - if (ret == ESP_OK && total_sent != (size_t)st.st_size) { - ESP_LOGW( - TAG, - "download size mismatch for %s: expected=%lld sent=%zu", - path, - (long long)st.st_size, - total_sent - ); + if (ret == ESP_OK && total_sent == (size_t)st.st_size) { + ret = httpd_resp_send_chunk(req, NULL, 0); + } else if (ret == ESP_OK) { ret = ESP_FAIL; } xSemaphoreGive(s_radio_mutex); free(buffer); fclose(file); + + ESP_LOGI( + TAG, + "download session=%04lu bytes=%zu/%lld result=%s", + parsed, + total_sent, + (long long)st.st_size, + esp_err_to_name(ret) + ); + return ret; } @@ -602,6 +588,7 @@ static esp_err_t start_status_server(void) httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.stack_size = BANDIT_UPLINK_HTTP_STACK; config.max_uri_handlers = 5; + config.send_wait_timeout = 20; esp_err_t ret = httpd_start(&s_server, &config); if (ret != ESP_OK) { From acda99f793bad97da99f3227f0c94acd7955a51d Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:20:57 -0400 Subject: [PATCH 22/48] fix: identify browser-managed CSV download build --- main/bandit_version.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main/bandit_version.h b/main/bandit_version.h index dd6d513..6938853 100644 --- a/main/bandit_version.h +++ b/main/bandit_version.h @@ -1,4 +1,4 @@ #pragma once -#define BANDIT_VERSION "v0.4.2 WEB DL RADIO" -#define BANDIT_VERSION_SHORT "v0.4.2" +#define BANDIT_VERSION "v0.4.3 WEB DL FETCH" +#define BANDIT_VERSION_SHORT "v0.4.3" From 7122124c07a49dafa76d25686f978083c521cc53 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:21:01 -0400 Subject: [PATCH 23/48] docs: explain browser-managed CSV transfer --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index dc2d0c7..137c62f 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,25 @@ stalled at zero bytes when an active dual-band scan moved the STA off-channel. The web UI also retains the last known status/table during brief server-busy periods instead of replacing good data with a transient unavailable message. + +### Browser-managed CSV downloads + +v0.4.3 removes the hand-framed fixed-length HTTP response used in v0.4.1/v0.4.2. +Completed-session CSVs are again sent with ESP-IDF's supported chunked response +API, but the status page now fetches the file itself, tracks received bytes +against the known session size, assembles a browser Blob, and triggers the local +download only after the transfer completes. + +While a file transfer is active: +- normal status/AP/session polling is paused +- the shared radio mutex prevents new RF sweeps +- the HTTP send timeout is extended to 20 seconds +- polling and scanning resume automatically after success or failure + +This avoids mobile download-manager quirks around local chunked attachments and +also avoids leaving the ESP-IDF HTTP server in an inconsistent state after a +manually framed response. + ### v0.3.1 recovery landmark The branch `release/v0.3.1` points at the exact physically validated v0.3.1 mainline commit. The connected GitHub tooling used for this project can mutate branches/PRs but does not expose Git tag/release-asset creation, so this branch is the durable recovery pointer until a GitHub Release/tag is created separately. From 94e836a5dbe92420b48c42aed90b8b9ded1731a6 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:12:25 -0400 Subject: [PATCH 24/48] hardening: define synchronized storage reader API --- main/bandit_storage.h | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/main/bandit_storage.h b/main/bandit_storage.h index 4fc4073..c76821d 100644 --- a/main/bandit_storage.h +++ b/main/bandit_storage.h @@ -20,12 +20,17 @@ typedef struct { bool active; } bandit_storage_session_t; +typedef struct bandit_storage_reader bandit_storage_reader_t; + esp_err_t bandit_storage_init(void); void bandit_storage_service(void); void bandit_storage_log_record(const bandit_scan_record_t *record, void *ctx); void bandit_storage_finish_scan(uint32_t generation); bandit_storage_state_t bandit_storage_get_state(void); -const char *bandit_storage_get_session_path(void); +esp_err_t bandit_storage_get_session_path( + char *path, + size_t path_size +); esp_err_t bandit_storage_list_sessions( bandit_storage_session_t *sessions, size_t capacity, @@ -36,4 +41,16 @@ esp_err_t bandit_storage_session_path( char *path, size_t path_size ); +esp_err_t bandit_storage_open_completed_session( + unsigned index, + bandit_storage_reader_t **reader, + size_t *size_bytes +); +size_t bandit_storage_read_completed_session( + bandit_storage_reader_t *reader, + void *buffer, + size_t size, + bool *eof +); +void bandit_storage_close_completed_session(bandit_storage_reader_t *reader); void bandit_storage_deinit(void); From 40fa754cd97e81b18e463297b2de6ff7456bbfd9 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:13:59 -0400 Subject: [PATCH 25/48] hardening: serialize TF lifetime and lease web readers --- main/bandit_storage.c | 531 +++++++++++++++++++++++++++++++++--------- 1 file changed, 418 insertions(+), 113 deletions(-) diff --git a/main/bandit_storage.c b/main/bandit_storage.c index b36e20a..003a52a 100644 --- a/main/bandit_storage.c +++ b/main/bandit_storage.c @@ -10,6 +10,8 @@ #include #include "bsp/esp-bsp.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" #include "esp_log.h" #include "esp_timer.h" @@ -21,51 +23,119 @@ static const char *TAG = "bandit_storage"; +struct bandit_storage_reader { + FILE *file; + size_t size_bytes; + unsigned index; +}; + static FILE *s_log_file; static bandit_storage_state_t s_state = BANDIT_STORAGE_NO_CARD; static char s_session_path[BANDIT_SESSION_PATH_MAX]; static uint32_t s_scans_since_flush; static bool s_mounted; +static bool s_unmount_pending; +static unsigned s_reader_count; static int64_t s_next_retry_ms; +static SemaphoreHandle_t s_mutex; static int64_t now_ms(void) { return esp_timer_get_time() / 1000; } -static void schedule_retry(void) +static void schedule_retry_locked(void) { s_next_retry_ms = now_ms() + BANDIT_STORAGE_RETRY_MS; } -static void teardown_storage(bandit_storage_state_t next_state) +static bool parse_session_filename(const char *name, unsigned *index) { - if (s_log_file) { - (void)fclose(s_log_file); - s_log_file = NULL; + if (!name || strlen(name) != strlen("session-0000.csv")) { + return false; } - if (s_mounted) { - esp_err_t ret = bsp_sdcard_unmount(); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "TF unmount returned %s", esp_err_to_name(ret)); + if (strncmp(name, "session-", 8) != 0 || + strcmp(name + 12, ".csv") != 0) { + return false; + } + + unsigned value = 0; + for (size_t i = 8; i < 12; i++) { + if (name[i] < '0' || name[i] > '9') { + return false; } - s_mounted = false; + value = (value * 10U) + (unsigned)(name[i] - '0'); + } + + if (value == 0 || value > BANDIT_SESSION_LIMIT) { + return false; + } + + if (index) { + *index = value; } + return true; +} + +static void unmount_now_locked(void) +{ + if (!s_mounted) { + s_unmount_pending = false; + return; + } + + esp_err_t ret = bsp_sdcard_unmount(); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "TF unmount returned %s", esp_err_to_name(ret)); + } + + s_mounted = false; + s_unmount_pending = false; +} + +static void complete_pending_unmount_locked(void) +{ + if (s_unmount_pending && s_reader_count == 0) { + unmount_now_locked(); + } +} +static void teardown_storage_locked(bandit_storage_state_t next_state) +{ + if (s_log_file) { + (void)fclose(s_log_file); + s_log_file = NULL; + } + + // Publish the volume as unavailable before touching the FAT/VFS mount so + // the HTTP task cannot acquire a new reader while teardown is in progress. s_state = next_state; s_session_path[0] = '\0'; s_scans_since_flush = 0; + + if (s_mounted) { + if (s_reader_count == 0) { + unmount_now_locked(); + } else { + s_unmount_pending = true; + ESP_LOGW( + TAG, + "deferring TF unmount for %u active reader(s)", + s_reader_count + ); + } + } } -static void set_error(const char *reason) +static void set_error_locked(const char *reason) { ESP_LOGE(TAG, "%s", reason ? reason : "storage error"); - teardown_storage(BANDIT_STORAGE_ERROR); - schedule_retry(); + teardown_storage_locked(BANDIT_STORAGE_ERROR); + schedule_retry_locked(); } -static esp_err_t flush_log(void) +static esp_err_t flush_log_locked(void) { if (!s_log_file) { return ESP_ERR_INVALID_STATE; @@ -85,7 +155,7 @@ static esp_err_t flush_log(void) return ESP_OK; } -static esp_err_t ensure_log_directory(void) +static esp_err_t ensure_log_directory_locked(void) { if (mkdir(BANDIT_LOG_DIR, 0775) == 0 || errno == EEXIST) { return ESP_OK; @@ -95,34 +165,72 @@ static esp_err_t ensure_log_directory(void) return ESP_FAIL; } -static esp_err_t choose_session_path(void) +esp_err_t bandit_storage_session_path( + unsigned index, + char *path, + size_t path_size +) { - struct stat st; + if (!path || path_size == 0 || index == 0 || index > BANDIT_SESSION_LIMIT) { + return ESP_ERR_INVALID_ARG; + } - for (unsigned index = 1; index <= BANDIT_SESSION_LIMIT; index++) { - int written = snprintf( - s_session_path, - sizeof(s_session_path), - BANDIT_LOG_DIR "/session-%04u.csv", - index - ); + int written = snprintf( + path, + path_size, + BANDIT_LOG_DIR "/session-%04u.csv", + index + ); - if (written <= 0 || written >= (int)sizeof(s_session_path)) { - return ESP_ERR_INVALID_SIZE; - } + if (written <= 0 || written >= (int)path_size) { + return ESP_ERR_INVALID_SIZE; + } - if (stat(s_session_path, &st) != 0) { - if (errno == ENOENT) { - return ESP_OK; - } + return ESP_OK; +} + +static esp_err_t choose_session_path_locked(void) +{ + DIR *dir = opendir(BANDIT_LOG_DIR); + if (!dir) { + ESP_LOGE(TAG, "opendir %s failed: %s", BANDIT_LOG_DIR, strerror(errno)); + return ESP_FAIL; + } - ESP_LOGE(TAG, "stat %s failed: %s", s_session_path, strerror(errno)); - return ESP_FAIL; + unsigned highest = 0; + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) { + unsigned index = 0; + if (parse_session_filename(entry->d_name, &index) && index > highest) { + highest = index; } } + closedir(dir); + + if (highest >= BANDIT_SESSION_LIMIT) { + ESP_LOGE(TAG, "session namespace exhausted"); + return ESP_ERR_NO_MEM; + } + + return bandit_storage_session_path( + highest + 1U, + s_session_path, + sizeof(s_session_path) + ); +} + +static bool csv_formula_prefix_needed(const char *text) +{ + if (!text || !text[0]) { + return false; + } - ESP_LOGE(TAG, "session namespace exhausted"); - return ESP_ERR_NO_MEM; + return text[0] == '=' || + text[0] == '+' || + text[0] == '-' || + text[0] == '@' || + text[0] == '\t' || + text[0] == '\r'; } static bool write_csv_text(FILE *file, const char *text) @@ -131,7 +239,12 @@ static bool write_csv_text(FILE *file, const char *text) return false; } - for (const char *p = text ? text : ""; *p; p++) { + const char *safe = text ? text : ""; + if (csv_formula_prefix_needed(safe) && fputc('\'', file) == EOF) { + return false; + } + + for (const char *p = safe; *p; p++) { if (*p == '"') { if (fputc('"', file) == EOF) { return false; @@ -146,40 +259,52 @@ static bool write_csv_text(FILE *file, const char *text) return fputc('"', file) != EOF; } -static esp_err_t start_session(void) +static esp_err_t start_session_locked(void) { + complete_pending_unmount_locked(); + + if (s_reader_count != 0 || s_unmount_pending) { + schedule_retry_locked(); + return ESP_ERR_INVALID_STATE; + } + if (s_log_file || s_mounted) { - teardown_storage(BANDIT_STORAGE_NO_CARD); + teardown_storage_locked(BANDIT_STORAGE_NO_CARD); + complete_pending_unmount_locked(); + if (s_mounted) { + schedule_retry_locked(); + return ESP_ERR_INVALID_STATE; + } } esp_err_t ret = bsp_sdcard_mount(); if (ret != ESP_OK) { s_state = BANDIT_STORAGE_NO_CARD; s_session_path[0] = '\0'; - schedule_retry(); + schedule_retry_locked(); return ret; } s_mounted = true; - ret = ensure_log_directory(); + ret = ensure_log_directory_locked(); if (ret != ESP_OK) { - teardown_storage(BANDIT_STORAGE_ERROR); - schedule_retry(); + teardown_storage_locked(BANDIT_STORAGE_ERROR); + schedule_retry_locked(); return ret; } - ret = choose_session_path(); + ret = choose_session_path_locked(); if (ret != ESP_OK) { - teardown_storage(BANDIT_STORAGE_ERROR); - schedule_retry(); + teardown_storage_locked(BANDIT_STORAGE_ERROR); + schedule_retry_locked(); return ret; } s_log_file = fopen(s_session_path, "wb"); if (!s_log_file) { ESP_LOGE(TAG, "open %s failed: %s", s_session_path, strerror(errno)); - teardown_storage(BANDIT_STORAGE_ERROR); - schedule_retry(); + teardown_storage_locked(BANDIT_STORAGE_ERROR); + schedule_retry_locked(); return ESP_FAIL; } @@ -187,8 +312,13 @@ static esp_err_t start_session(void) "uptime_ms,scan,bssid,ssid,rssi,channel,band,auth,hidden\n", s_log_file ) == EOF || - flush_log() != ESP_OK) { - set_error("failed to initialize session log"); + flush_log_locked() != ESP_OK) { + char failed_path[BANDIT_SESSION_PATH_MAX]; + snprintf(failed_path, sizeof(failed_path), "%s", s_session_path); + (void)fclose(s_log_file); + s_log_file = NULL; + (void)unlink(failed_path); + set_error_locked("failed to initialize session log"); return ESP_FAIL; } @@ -202,10 +332,19 @@ static esp_err_t start_session(void) esp_err_t bandit_storage_init(void) { - teardown_storage(BANDIT_STORAGE_NO_CARD); + if (!s_mutex) { + s_mutex = xSemaphoreCreateMutex(); + if (!s_mutex) { + return ESP_ERR_NO_MEM; + } + } + + xSemaphoreTake(s_mutex, portMAX_DELAY); + teardown_storage_locked(BANDIT_STORAGE_NO_CARD); s_next_retry_ms = 0; + esp_err_t ret = start_session_locked(); + xSemaphoreGive(s_mutex); - esp_err_t ret = start_session(); if (ret != ESP_OK) { ESP_LOGW(TAG, "TF card unavailable: %s", esp_err_to_name(ret)); } @@ -215,36 +354,55 @@ esp_err_t bandit_storage_init(void) void bandit_storage_service(void) { - if (s_state == BANDIT_STORAGE_READY) { + if (!s_mutex) { + return; + } + + xSemaphoreTake(s_mutex, portMAX_DELAY); + + complete_pending_unmount_locked(); + + if (s_state == BANDIT_STORAGE_READY || + s_reader_count != 0 || + s_unmount_pending) { + xSemaphoreGive(s_mutex); return; } const int64_t current_ms = now_ms(); if (s_next_retry_ms != 0 && current_ms < s_next_retry_ms) { + xSemaphoreGive(s_mutex); return; } ESP_LOGI(TAG, "retrying TF card mount"); - esp_err_t ret = start_session(); + esp_err_t ret = start_session_locked(); if (ret == ESP_OK) { ESP_LOGI(TAG, "TF card recovered"); } else { - // A mount failure means no usable card is present. Filesystem/open failures - // leave the ERROR state set by start_session() so the UI can distinguish them. if (s_state != BANDIT_STORAGE_ERROR) { s_state = BANDIT_STORAGE_NO_CARD; } - schedule_retry(); + schedule_retry_locked(); ESP_LOGD(TAG, "TF retry unavailable: %s", esp_err_to_name(ret)); } + + xSemaphoreGive(s_mutex); } void bandit_storage_log_record(const bandit_scan_record_t *record, void *ctx) { (void)ctx; - if (!record || s_state != BANDIT_STORAGE_READY || !s_log_file) { + if (!record || !s_mutex) { + return; + } + + xSemaphoreTake(s_mutex, portMAX_DELAY); + + if (s_state != BANDIT_STORAGE_READY || !s_log_file) { + xSemaphoreGive(s_mutex); return; } @@ -284,41 +442,76 @@ void bandit_storage_log_record(const bandit_scan_record_t *record, void *ctx) ) >= 0; if (!ok) { - set_error("TF write failed"); + set_error_locked("TF write failed"); } + + xSemaphoreGive(s_mutex); } void bandit_storage_finish_scan(uint32_t generation) { (void)generation; + if (!s_mutex) { + return; + } + + xSemaphoreTake(s_mutex, portMAX_DELAY); + if (s_state != BANDIT_STORAGE_READY || !s_log_file) { + xSemaphoreGive(s_mutex); return; } s_scans_since_flush++; if (s_scans_since_flush < BANDIT_FLUSH_SCAN_INTERVAL) { + xSemaphoreGive(s_mutex); return; } - if (flush_log() != ESP_OK) { - set_error("TF flush failed"); + if (flush_log_locked() != ESP_OK) { + set_error_locked("TF flush failed"); + xSemaphoreGive(s_mutex); return; } s_scans_since_flush = 0; + xSemaphoreGive(s_mutex); } bandit_storage_state_t bandit_storage_get_state(void) { - return s_state; + if (!s_mutex) { + return s_state; + } + + xSemaphoreTake(s_mutex, portMAX_DELAY); + bandit_storage_state_t state = s_state; + xSemaphoreGive(s_mutex); + return state; } -const char *bandit_storage_get_session_path(void) +esp_err_t bandit_storage_get_session_path(char *path, size_t path_size) { - return s_session_path; -} + if (!path || path_size == 0) { + return ESP_ERR_INVALID_ARG; + } + + if (!s_mutex) { + path[0] = '\0'; + return ESP_ERR_INVALID_STATE; + } + + xSemaphoreTake(s_mutex, portMAX_DELAY); + int written = snprintf(path, path_size, "%s", s_session_path); + xSemaphoreGive(s_mutex); + + if (written < 0 || written >= (int)path_size) { + return ESP_ERR_INVALID_SIZE; + } + return path[0] ? ESP_OK : ESP_ERR_INVALID_STATE; +} static int compare_session_desc(const void *left, const void *right) { @@ -334,78 +527,44 @@ static int compare_session_desc(const void *left, const void *right) return 0; } -esp_err_t bandit_storage_session_path( - unsigned index, - char *path, - size_t path_size -) -{ - if (!path || path_size == 0 || index == 0 || index > BANDIT_SESSION_LIMIT) { - return ESP_ERR_INVALID_ARG; - } - - int written = snprintf( - path, - path_size, - BANDIT_LOG_DIR "/session-%04u.csv", - index - ); - - if (written <= 0 || written >= (int)path_size) { - return ESP_ERR_INVALID_SIZE; - } - - return ESP_OK; -} - esp_err_t bandit_storage_list_sessions( bandit_storage_session_t *sessions, size_t capacity, size_t *count ) { - if (!sessions || capacity == 0 || !count) { + if (!sessions || capacity == 0 || !count || !s_mutex) { return ESP_ERR_INVALID_ARG; } *count = 0; + xSemaphoreTake(s_mutex, portMAX_DELAY); - if (!s_mounted || s_state != BANDIT_STORAGE_READY) { + if (!s_mounted || + s_state != BANDIT_STORAGE_READY || + s_unmount_pending) { + xSemaphoreGive(s_mutex); return ESP_ERR_INVALID_STATE; } DIR *dir = opendir(BANDIT_LOG_DIR); if (!dir) { ESP_LOGW(TAG, "opendir %s failed: %s", BANDIT_LOG_DIR, strerror(errno)); + xSemaphoreGive(s_mutex); return ESP_FAIL; } struct dirent *entry; while ((entry = readdir(dir)) != NULL) { unsigned index = 0; - int consumed = 0; - - if (sscanf(entry->d_name, "session-%4u.csv%n", &index, &consumed) != 1 || - entry->d_name[consumed] != '\0' || - index == 0 || - index > BANDIT_SESSION_LIMIT) { - continue; - } - - char path[BANDIT_SESSION_PATH_MAX]; - if (bandit_storage_session_path(index, path, sizeof(path)) != ESP_OK) { - continue; - } - - struct stat st; - if (stat(path, &st) != 0) { + if (!parse_session_filename(entry->d_name, &index)) { continue; } bandit_storage_session_t candidate = { .index = index, - .size_bytes = (size_t)st.st_size, - .active = strcmp(path, s_session_path) == 0, + .size_bytes = 0, + .active = false, }; if (*count < capacity) { @@ -413,8 +572,6 @@ esp_err_t bandit_storage_list_sessions( continue; } - // Capacity is intentionally bounded for the web UI. Retain the newest - // session numbers if the card contains more files than fit in one page. size_t oldest = 0; for (size_t i = 1; i < capacity; i++) { if (sessions[i].index < sessions[oldest].index) { @@ -426,18 +583,166 @@ esp_err_t bandit_storage_list_sessions( sessions[oldest] = candidate; } } - closedir(dir); + + size_t valid_count = 0; + for (size_t i = 0; i < *count; i++) { + char path[BANDIT_SESSION_PATH_MAX]; + if (bandit_storage_session_path( + sessions[i].index, + path, + sizeof(path) + ) != ESP_OK) { + continue; + } + + struct stat st; + if (stat(path, &st) != 0 || st.st_size < 0) { + continue; + } + + sessions[valid_count] = sessions[i]; + sessions[valid_count].size_bytes = (size_t)st.st_size; + sessions[valid_count].active = + strcmp(path, s_session_path) == 0; + valid_count++; + } + + *count = valid_count; qsort(sessions, *count, sizeof(*sessions), compare_session_desc); + xSemaphoreGive(s_mutex); + return ESP_OK; +} + +esp_err_t bandit_storage_open_completed_session( + unsigned index, + bandit_storage_reader_t **reader, + size_t *size_bytes +) +{ + if (!reader || !size_bytes || !s_mutex) { + return ESP_ERR_INVALID_ARG; + } + + *reader = NULL; + *size_bytes = 0; + + bandit_storage_reader_t *candidate = calloc(1, sizeof(*candidate)); + if (!candidate) { + return ESP_ERR_NO_MEM; + } + + char path[BANDIT_SESSION_PATH_MAX]; + esp_err_t ret = bandit_storage_session_path(index, path, sizeof(path)); + if (ret != ESP_OK) { + free(candidate); + return ret; + } + + xSemaphoreTake(s_mutex, portMAX_DELAY); + + if (!s_mounted || + s_state != BANDIT_STORAGE_READY || + s_unmount_pending) { + xSemaphoreGive(s_mutex); + free(candidate); + return ESP_ERR_INVALID_STATE; + } + + if (strcmp(path, s_session_path) == 0) { + xSemaphoreGive(s_mutex); + free(candidate); + return ESP_ERR_INVALID_STATE; + } + + FILE *file = fopen(path, "rb"); + if (!file) { + xSemaphoreGive(s_mutex); + free(candidate); + return ESP_ERR_NOT_FOUND; + } + + struct stat st; + if (fstat(fileno(file), &st) != 0 || st.st_size < 0) { + fclose(file); + xSemaphoreGive(s_mutex); + free(candidate); + return ESP_FAIL; + } + + candidate->file = file; + candidate->size_bytes = (size_t)st.st_size; + candidate->index = index; + s_reader_count++; + + xSemaphoreGive(s_mutex); + + *reader = candidate; + *size_bytes = candidate->size_bytes; return ESP_OK; } +size_t bandit_storage_read_completed_session( + bandit_storage_reader_t *reader, + void *buffer, + size_t size, + bool *eof +) +{ + if (eof) { + *eof = false; + } + + if (!reader || !reader->file || !buffer || size == 0) { + return 0; + } + + size_t count = fread(buffer, 1, size, reader->file); + if (eof) { + *eof = feof(reader->file) != 0; + } + return count; +} + +void bandit_storage_close_completed_session(bandit_storage_reader_t *reader) +{ + if (!reader) { + return; + } + + if (reader->file) { + (void)fclose(reader->file); + reader->file = NULL; + } + + if (s_mutex) { + xSemaphoreTake(s_mutex, portMAX_DELAY); + + if (s_reader_count > 0) { + s_reader_count--; + } + complete_pending_unmount_locked(); + + xSemaphoreGive(s_mutex); + } + + free(reader); +} + void bandit_storage_deinit(void) { + if (!s_mutex) { + return; + } + + xSemaphoreTake(s_mutex, portMAX_DELAY); + if (s_log_file) { - (void)flush_log(); + (void)flush_log_locked(); } - teardown_storage(BANDIT_STORAGE_NO_CARD); + teardown_storage_locked(BANDIT_STORAGE_NO_CARD); s_next_retry_ms = 0; + + xSemaphoreGive(s_mutex); } From 7c00a940742da33620081e2cca8a77b341cf39ae Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:14:42 -0400 Subject: [PATCH 26/48] hardening: make radio scan arbitration nonblocking --- main/bandit_uplink.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/main/bandit_uplink.h b/main/bandit_uplink.h index 121cd32..74a5028 100644 --- a/main/bandit_uplink.h +++ b/main/bandit_uplink.h @@ -9,8 +9,9 @@ esp_err_t bandit_uplink_init(void); void bandit_uplink_service(void); void bandit_uplink_stop(void); -void bandit_uplink_begin_scan(void); +bool bandit_uplink_try_begin_scan(void); void bandit_uplink_end_scan(void); +bool bandit_uplink_first_attempt_resolved(void); void bandit_uplink_observe_record(const bandit_scan_record_t *record); void bandit_uplink_publish_snapshot(const bandit_scan_snapshot_t *snapshot); From 1630467203fa73c47aef3c7acbda5f06e9b3cdff Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:16:26 -0400 Subject: [PATCH 27/48] hardening: serialize uplink state without blocking scout loop --- main/bandit_uplink.c | 375 ++++++++++++++++++++++++++++++------------- 1 file changed, 264 insertions(+), 111 deletions(-) diff --git a/main/bandit_uplink.c b/main/bandit_uplink.c index 8cc93fc..a2f7eda 100644 --- a/main/bandit_uplink.c +++ b/main/bandit_uplink.c @@ -29,6 +29,8 @@ #define BANDIT_UPLINK_NVS_SSID "ssid" #define BANDIT_UPLINK_NVS_PASSWORD "password" #define BANDIT_UPLINK_RETRY_MS 15000 +#define BANDIT_UPLINK_CONNECT_TIMEOUT_MS 10000 +#define BANDIT_UPLINK_DOWNLOAD_MAX_MS 30000 #define BANDIT_UPLINK_HTTP_STACK 8192 #define BANDIT_UPLINK_AP_CACHE_MAX 64 #define BANDIT_UPLINK_SESSION_LIST_MAX 32 @@ -38,10 +40,15 @@ static const char *TAG = "bandit_uplink"; static volatile bool s_enabled; static bool s_connected; +static bool s_connecting; +static bool s_first_attempt_resolved; +static bool s_services_pending; +static bool s_state_dirty; static char s_ssid[33]; static char s_ip[16]; static httpd_handle_t s_server; static int64_t s_next_retry_ms; +static int64_t s_connect_deadline_ms; static esp_event_handler_instance_t s_wifi_handler; static esp_event_handler_instance_t s_ip_handler; static bool s_wifi_handler_registered; @@ -470,50 +477,43 @@ static esp_err_t download_handler(httpd_req_t *req) return ESP_ERR_INVALID_ARG; } - char path[96]; - esp_err_t ret = bandit_storage_session_path( - (unsigned)parsed, - path, - sizeof(path) - ); - if (ret != ESP_OK) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid session path"); - return ret; - } - - const char *active_path = bandit_storage_get_session_path(); - if (active_path && active_path[0] && strcmp(path, active_path) == 0) { - httpd_resp_set_status(req, "409 Conflict"); - return httpd_resp_sendstr(req, "Active session is still being written"); - } - - struct stat st; - if (stat(path, &st) != 0 || st.st_size < 0) { - httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Session not found"); - return ESP_ERR_NOT_FOUND; - } - - FILE *file = fopen(path, "rb"); - if (!file) { - httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Session not found"); - return ESP_ERR_NOT_FOUND; - } - char *buffer = malloc(BANDIT_UPLINK_DOWNLOAD_BUFFER); if (!buffer) { - fclose(file); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory"); return ESP_ERR_NO_MEM; } - if (!s_radio_mutex || + if (s_radio_mutex && xSemaphoreTake(s_radio_mutex, pdMS_TO_TICKS(15000)) != pdTRUE) { free(buffer); - fclose(file); httpd_resp_set_status(req, "503 Service Unavailable"); return httpd_resp_sendstr(req, "Radio busy; try the download again"); } + bandit_storage_reader_t *reader = NULL; + size_t file_size = 0; + esp_err_t ret = bandit_storage_open_completed_session( + (unsigned)parsed, + &reader, + &file_size + ); + if (ret != ESP_OK) { + if (s_radio_mutex) { + xSemaphoreGive(s_radio_mutex); + } + free(buffer); + + if (ret == ESP_ERR_NOT_FOUND) { + httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Session not found"); + } else if (ret == ESP_ERR_INVALID_STATE) { + httpd_resp_set_status(req, "409 Conflict"); + httpd_resp_sendstr(req, "Session unavailable or still active"); + } else { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Unable to open session"); + } + return ret; + } + char disposition[64]; char size_header[24]; snprintf( @@ -522,57 +522,75 @@ static esp_err_t download_handler(httpd_req_t *req) "attachment; filename=\"session-%04lu.csv\"", parsed ); - snprintf( - size_header, - sizeof(size_header), - "%lld", - (long long)st.st_size - ); + snprintf(size_header, sizeof(size_header), "%zu", file_size); httpd_resp_set_type(req, "text/csv; charset=utf-8"); httpd_resp_set_hdr(req, "Content-Disposition", disposition); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); httpd_resp_set_hdr(req, "X-BandiC5-File-Size", size_header); + const int64_t started_ms = esp_timer_get_time() / 1000; size_t total_sent = 0; - while (total_sent < (size_t)st.st_size) { - size_t remaining = (size_t)st.st_size - total_sent; + + while (total_sent < file_size) { + if ((esp_timer_get_time() / 1000) - started_ms > + BANDIT_UPLINK_DOWNLOAD_MAX_MS) { + ESP_LOGW(TAG, "download timed out: session=%04lu", parsed); + ret = ESP_ERR_TIMEOUT; + break; + } + + size_t remaining = file_size - total_sent; size_t request = remaining < BANDIT_UPLINK_DOWNLOAD_BUFFER ? remaining : BANDIT_UPLINK_DOWNLOAD_BUFFER; - size_t read_count = fread(buffer, 1, request, file); + bool eof = false; + size_t read_count = bandit_storage_read_completed_session( + reader, + buffer, + request, + &eof + ); + if (read_count == 0) { - ESP_LOGW(TAG, "short read while downloading %s", path); + ESP_LOGW( + TAG, + "short read while downloading session=%04lu eof=%d", + parsed, + eof + ); ret = ESP_FAIL; break; } ret = httpd_resp_send_chunk(req, buffer, read_count); if (ret != ESP_OK) { - ESP_LOGW(TAG, "send failed while downloading %s", path); + ESP_LOGW(TAG, "send failed while downloading session=%04lu", parsed); break; } total_sent += read_count; } - if (ret == ESP_OK && total_sent == (size_t)st.st_size) { + if (ret == ESP_OK && total_sent == file_size) { ret = httpd_resp_send_chunk(req, NULL, 0); } else if (ret == ESP_OK) { ret = ESP_FAIL; } - xSemaphoreGive(s_radio_mutex); + bandit_storage_close_completed_session(reader); + if (s_radio_mutex) { + xSemaphoreGive(s_radio_mutex); + } free(buffer); - fclose(file); ESP_LOGI( TAG, - "download session=%04lu bytes=%zu/%lld result=%s", + "download session=%04lu bytes=%zu/%zu result=%s", parsed, total_sent, - (long long)st.st_size, + file_size, esp_err_to_name(ret) ); @@ -589,6 +607,11 @@ static esp_err_t start_status_server(void) config.stack_size = BANDIT_UPLINK_HTTP_STACK; config.max_uri_handlers = 5; config.send_wait_timeout = 20; + config.lru_purge_enable = true; + config.keep_alive_enable = true; + config.keep_alive_idle = 10; + config.keep_alive_interval = 3; + config.keep_alive_count = 3; esp_err_t ret = httpd_start(&s_server, &config); if (ret != ESP_OK) { @@ -658,19 +681,21 @@ static void wifi_event_handler( (void)event_base; (void)event_data; - if (!s_enabled) { + if (!s_enabled || event_id != WIFI_EVENT_STA_DISCONNECTED) { return; } - if (event_id == WIFI_EVENT_STA_DISCONNECTED) { - portENTER_CRITICAL(&s_state_mux); - s_connected = false; - s_ip[0] = '\0'; - portEXIT_CRITICAL(&s_state_mux); - bandit_ui_set_uplink_state(true, false); - bandit_ui_set_uplink_address(NULL); - ESP_LOGW(TAG, "uplink disconnected; reconnect scheduled between scans"); - } + const int64_t current_ms = esp_timer_get_time() / 1000; + + portENTER_CRITICAL(&s_state_mux); + s_connected = false; + s_connecting = false; + s_first_attempt_resolved = true; + s_services_pending = false; + s_state_dirty = true; + s_ip[0] = '\0'; + s_next_retry_ms = current_ms + BANDIT_UPLINK_RETRY_MS; + portEXIT_CRITICAL(&s_state_mux); } static void ip_event_handler( @@ -683,38 +708,38 @@ static void ip_event_handler( (void)arg; (void)event_base; - if (!s_enabled || event_id != IP_EVENT_STA_GOT_IP) { + if (!s_enabled) { return; } - const ip_event_got_ip_t *event = (const ip_event_got_ip_t *)event_data; - char ip[16]; - snprintf(ip, sizeof(ip), IPSTR, IP2STR(&event->ip_info.ip)); + if (event_id == IP_EVENT_STA_GOT_IP) { + const ip_event_got_ip_t *event = (const ip_event_got_ip_t *)event_data; + char ip[16]; + snprintf(ip, sizeof(ip), IPSTR, IP2STR(&event->ip_info.ip)); - portENTER_CRITICAL(&s_state_mux); - s_connected = true; - snprintf(s_ip, sizeof(s_ip), "%s", ip); - portEXIT_CRITICAL(&s_state_mux); - - bandit_ui_set_uplink_state(true, true); - bandit_ui_set_uplink_address(ip); - ESP_LOGI(TAG, "uplink connected: SSID=%s IP=%s hostname=bandic5", s_ssid, ip); + portENTER_CRITICAL(&s_state_mux); + s_connected = true; + s_connecting = false; + s_first_attempt_resolved = true; + s_services_pending = true; + s_state_dirty = true; + snprintf(s_ip, sizeof(s_ip), "%s", ip); + portEXIT_CRITICAL(&s_state_mux); + return; + } - esp_err_t ret = start_status_server(); - if (ret == ESP_OK) { - if (!s_mdns_started) { - esp_err_t mdns_ret = mdns_init(); - if (mdns_ret == ESP_OK) { - (void)mdns_hostname_set("bandic5"); - (void)mdns_instance_name_set("C5 Bandit"); - (void)mdns_service_add(NULL, "_http", "_tcp", 80, NULL, 0); - s_mdns_started = true; - } else { - ESP_LOGW(TAG, "mDNS init failed: %s", esp_err_to_name(mdns_ret)); - } - } + if (event_id == IP_EVENT_STA_LOST_IP) { + const int64_t current_ms = esp_timer_get_time() / 1000; - ESP_LOGI(TAG, "status page ready: http://%s/ (try http://bandic5.local/)", ip); + portENTER_CRITICAL(&s_state_mux); + s_connected = false; + s_connecting = false; + s_first_attempt_resolved = true; + s_services_pending = false; + s_state_dirty = true; + s_ip[0] = '\0'; + s_next_retry_ms = current_ms + BANDIT_UPLINK_RETRY_MS; + portEXIT_CRITICAL(&s_state_mux); } } @@ -827,6 +852,9 @@ esp_err_t bandit_uplink_init(void) esp_err_t ret = load_credentials(ssid, password); if (ret == ESP_ERR_NVS_NOT_FOUND || (ret == ESP_OK && ssid[0] == '\0')) { + portENTER_CRITICAL(&s_state_mux); + s_first_attempt_resolved = true; + portEXIT_CRITICAL(&s_state_mux); bandit_ui_set_uplink_state(false, false); ESP_LOGI(TAG, "uplink not configured"); return ESP_OK; @@ -865,19 +893,26 @@ esp_err_t bandit_uplink_init(void) ret = esp_event_handler_instance_register( IP_EVENT, - IP_EVENT_STA_GOT_IP, + ESP_EVENT_ANY_ID, ip_event_handler, NULL, &s_ip_handler ); if (ret != ESP_OK) { - esp_event_handler_instance_unregister(WIFI_EVENT, WIFI_EVENT_STA_DISCONNECTED, s_wifi_handler); + esp_event_handler_instance_unregister( + WIFI_EVENT, + WIFI_EVENT_STA_DISCONNECTED, + s_wifi_handler + ); s_wifi_handler_registered = false; return ret; } s_ip_handler_registered = true; - ret = esp_wifi_set_config(WIFI_IF_STA, &config); + ret = esp_wifi_set_storage(WIFI_STORAGE_RAM); + if (ret == ESP_OK) { + ret = esp_wifi_set_config(WIFI_IF_STA, &config); + } if (ret != ESP_OK) { bandit_uplink_stop(); return ret; @@ -887,12 +922,17 @@ esp_err_t bandit_uplink_init(void) snprintf(s_ssid, sizeof(s_ssid), "%s", ssid); s_ip[0] = '\0'; s_connected = false; + s_connecting = false; + s_first_attempt_resolved = false; + s_services_pending = false; + s_state_dirty = true; + s_next_retry_ms = 0; + s_connect_deadline_ms = 0; portEXIT_CRITICAL(&s_state_mux); s_enabled = true; bandit_ui_set_uplink_state(true, false); - - s_next_retry_ms = 0; + bandit_ui_set_uplink_address(NULL); ESP_LOGI(TAG, "uplink enabled for SSID=%s; first connect follows initial RF scan", s_ssid); return ESP_OK; @@ -904,45 +944,107 @@ void bandit_uplink_service(void) return; } + const int64_t current_ms = esp_timer_get_time() / 1000; bool connected; + bool connecting; + bool state_dirty; + bool services_pending; + int64_t next_retry_ms; + int64_t connect_deadline_ms; + char ssid[33]; + char ip[16]; + portENTER_CRITICAL(&s_state_mux); connected = s_connected; + connecting = s_connecting; + state_dirty = s_state_dirty; + services_pending = s_services_pending; + next_retry_ms = s_next_retry_ms; + connect_deadline_ms = s_connect_deadline_ms; + memcpy(ssid, s_ssid, sizeof(ssid)); + memcpy(ip, s_ip, sizeof(ip)); + s_state_dirty = false; + s_services_pending = false; portEXIT_CRITICAL(&s_state_mux); + if (state_dirty) { + bandit_ui_set_uplink_state(true, connected); + bandit_ui_set_uplink_address(connected ? ip : NULL); + + if (connected) { + ESP_LOGI(TAG, "uplink connected: SSID=%s IP=%s hostname=bandic5", ssid, ip); + } else { + ESP_LOGW(TAG, "uplink disconnected; reconnect scheduled between scans"); + } + } + if (connected) { + if (services_pending) { + esp_err_t ret = start_status_server(); + if (ret == ESP_OK && !s_mdns_started) { + esp_err_t mdns_ret = mdns_init(); + if (mdns_ret == ESP_OK) { + (void)mdns_hostname_set("bandic5"); + (void)mdns_instance_name_set("C5 Bandit"); + (void)mdns_service_add(NULL, "_http", "_tcp", 80, NULL, 0); + s_mdns_started = true; + } else { + ESP_LOGW(TAG, "mDNS init failed: %s", esp_err_to_name(mdns_ret)); + } + } + + if (ret == ESP_OK) { + ESP_LOGI(TAG, "status page ready: http://%s/ (try http://bandic5.local/)", ip); + } else { + portENTER_CRITICAL(&s_state_mux); + s_services_pending = true; + portEXIT_CRITICAL(&s_state_mux); + } + } return; } - const int64_t current_ms = esp_timer_get_time() / 1000; - if (s_next_retry_ms != 0 && current_ms < s_next_retry_ms) { + if (connecting) { + if (current_ms < connect_deadline_ms) { + return; + } + + ESP_LOGW(TAG, "uplink connect attempt timed out; returning radio to scout"); + (void)esp_wifi_disconnect(); + + portENTER_CRITICAL(&s_state_mux); + s_connecting = false; + s_first_attempt_resolved = true; + s_state_dirty = true; + s_next_retry_ms = current_ms + BANDIT_UPLINK_RETRY_MS; + portEXIT_CRITICAL(&s_state_mux); + return; + } + + if (next_retry_ms != 0 && current_ms < next_retry_ms) { return; } esp_err_t ret = esp_wifi_connect(); - if (ret != ESP_OK && ret != ESP_ERR_WIFI_CONN && ret != ESP_ERR_WIFI_STATE) { - ESP_LOGD(TAG, "uplink connect attempt: %s", esp_err_to_name(ret)); + if (ret == ESP_OK || ret == ESP_ERR_WIFI_STATE) { + portENTER_CRITICAL(&s_state_mux); + s_connecting = true; + s_connect_deadline_ms = current_ms + BANDIT_UPLINK_CONNECT_TIMEOUT_MS; + portEXIT_CRITICAL(&s_state_mux); + return; } + ESP_LOGD(TAG, "uplink connect attempt failed immediately: %s", esp_err_to_name(ret)); + portENTER_CRITICAL(&s_state_mux); + s_connecting = false; + s_first_attempt_resolved = true; s_next_retry_ms = current_ms + BANDIT_UPLINK_RETRY_MS; + portEXIT_CRITICAL(&s_state_mux); } void bandit_uplink_stop(void) { s_enabled = false; - s_next_retry_ms = 0; - - if (s_server) { - httpd_stop(s_server); - s_server = NULL; - } - - if (s_mdns_started) { - mdns_free(); - s_mdns_started = false; - } - - bandit_ui_set_uplink_state(false, false); - bandit_ui_set_uplink_address(NULL); if (s_wifi_handler_registered) { esp_event_handler_instance_unregister( @@ -956,26 +1058,68 @@ void bandit_uplink_stop(void) if (s_ip_handler_registered) { esp_event_handler_instance_unregister( IP_EVENT, - IP_EVENT_STA_GOT_IP, + ESP_EVENT_ANY_ID, s_ip_handler ); s_ip_handler_registered = false; } + if (s_server) { + httpd_stop(s_server); + s_server = NULL; + } + + if (s_mdns_started) { + mdns_free(); + s_mdns_started = false; + } + + bandit_ui_set_uplink_state(false, false); + bandit_ui_set_uplink_address(NULL); + portENTER_CRITICAL(&s_state_mux); s_connected = false; + s_connecting = false; + s_first_attempt_resolved = true; + s_services_pending = false; + s_state_dirty = false; + s_next_retry_ms = 0; + s_connect_deadline_ms = 0; s_ip[0] = '\0'; portEXIT_CRITICAL(&s_state_mux); } -void bandit_uplink_begin_scan(void) +bool bandit_uplink_try_begin_scan(void) { - if (s_radio_mutex) { - xSemaphoreTake(s_radio_mutex, portMAX_DELAY); + bool connecting = false; + + portENTER_CRITICAL(&s_state_mux); + connecting = s_connecting; + portEXIT_CRITICAL(&s_state_mux); + + if (connecting) { + return false; + } + + if (s_radio_mutex && + xSemaphoreTake(s_radio_mutex, 0) != pdTRUE) { + return false; + } + + portENTER_CRITICAL(&s_state_mux); + connecting = s_connecting; + portEXIT_CRITICAL(&s_state_mux); + + if (connecting) { + if (s_radio_mutex) { + xSemaphoreGive(s_radio_mutex); + } + return false; } s_work_ap_count = 0; s_work_seen = 0; + return true; } void bandit_uplink_end_scan(void) @@ -985,6 +1129,15 @@ void bandit_uplink_end_scan(void) } } + +bool bandit_uplink_first_attempt_resolved(void) +{ + portENTER_CRITICAL(&s_state_mux); + bool resolved = !s_enabled || s_first_attempt_resolved; + portEXIT_CRITICAL(&s_state_mux); + return resolved; +} + void bandit_uplink_observe_record(const bandit_scan_record_t *record) { if (!record) { From 679b13b9a76e25e85fa0830d124feeaa405d5164 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:17:05 -0400 Subject: [PATCH 28/48] hardening: keep scout loop live and defer OTA confirmation --- main/app_main.c | 44 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/main/app_main.c b/main/app_main.c index 3c04e97..75e75e9 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -2,6 +2,7 @@ #include "freertos/task.h" #include "esp_log.h" +#include "esp_timer.h" #include "esp_system.h" #include "bandit_ota.h" @@ -14,6 +15,7 @@ #define SCAN_TASK_STACK_SIZE (8 * 1024) #define SCAN_INTERVAL_MS 3500 #define OTA_REQUEST_POLL_MS 100 +#define OTA_CONFIRM_HEALTH_MS 30000 static const char *TAG = "bandic5"; @@ -83,6 +85,10 @@ static void scan_task(void *arg) { (void)arg; + bool first_scan_ok = false; + bool ota_confirmed = false; + int64_t healthy_since_ms = 0; + while (1) { if (bandit_ota_requested()) { enter_ota_mode(); @@ -92,8 +98,14 @@ static void scan_task(void *arg) bandit_ui_set_storage_state(storage_ui_state()); bandit_scan_snapshot_t snapshot; + + if (!bandit_uplink_try_begin_scan()) { + bandit_ui_set_status("RADIO BUSY"); + wait_for_next_scan_or_ota(); + continue; + } + bandit_ui_set_status("SCANNING"); - bandit_uplink_begin_scan(); esp_err_t ret = bandit_scan_once( &snapshot, @@ -108,6 +120,11 @@ static void scan_task(void *arg) bandit_ui_update(&snapshot); bandit_uplink_publish_snapshot(&snapshot); + if (!first_scan_ok) { + first_scan_ok = true; + healthy_since_ms = esp_timer_get_time() / 1000; + } + ESP_LOGI( TAG, "scan=%lu aps=%u 2g=%u 5g=%u strongest=%s rssi=%d ch=%u", @@ -119,12 +136,24 @@ static void scan_task(void *arg) snapshot.strongest_rssi, snapshot.strongest_channel ); + } else if (ret == ESP_ERR_WIFI_STATE) { + ESP_LOGW(TAG, "scan deferred while Wi-Fi driver is busy"); + bandit_ui_set_storage_state(storage_ui_state()); + bandit_ui_set_status("SCAN DEFER"); } else { ESP_LOGE(TAG, "scan failed: %s", esp_err_to_name(ret)); bandit_ui_set_storage_state(storage_ui_state()); bandit_ui_set_status("SCAN ERROR"); } + if (!ota_confirmed && + first_scan_ok && + bandit_uplink_first_attempt_resolved() && + (esp_timer_get_time() / 1000) - healthy_since_ms >= OTA_CONFIRM_HEALTH_MS) { + bandit_ota_confirm_running_image(); + ota_confirmed = true; + } + if (bandit_ota_requested()) { enter_ota_mode(); } @@ -149,7 +178,13 @@ void app_main(void) bandit_ui_set_storage_state(storage_ui_state()); if (ret == ESP_OK) { - ESP_LOGI(TAG, "TF logging enabled: %s", bandit_storage_get_session_path()); + char session_path[96]; + if (bandit_storage_get_session_path( + session_path, + sizeof(session_path) + ) == ESP_OK) { + ESP_LOGI(TAG, "TF logging enabled: %s", session_path); + } } else { ESP_LOGW( TAG, @@ -197,7 +232,6 @@ void app_main(void) return; } - // A newly OTA-flashed image is accepted only after display, radio, OTA trigger, - // and the main scan task have all initialized successfully. - bandit_ota_confirm_running_image(); + // Pending OTA images are confirmed by scan_task only after real runtime + // behavior has been exercised for the health window. } From 6e32a74fa3b523f9eaa94768f980a8d5f0659079 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:18:28 -0400 Subject: [PATCH 29/48] hardening: bound HTTP receive stalls in update mode --- main/bandit_ota.c | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/main/bandit_ota.c b/main/bandit_ota.c index 70cadad..d9286e1 100644 --- a/main/bandit_ota.c +++ b/main/bandit_ota.c @@ -27,6 +27,7 @@ #define BANDIT_OTA_POLL_MS 100 #define BANDIT_OTA_BUTTON_TASK_STACK 3072 #define BANDIT_OTA_RECV_BUFFER 4096 +#define BANDIT_OTA_MAX_RECV_TIMEOUTS 6 #define BANDIT_OTA_REBOOT_DELAY_MS 4000 static const char *TAG = "bandit_ota"; @@ -233,10 +234,16 @@ static esp_err_t wifi_config_post_handler(httpd_req_t *req) char body[384]; size_t remaining = req->content_len; size_t offset = 0; + unsigned timeout_count = 0; while (remaining > 0) { int received = httpd_req_recv(req, body + offset, remaining); if (received == HTTPD_SOCK_ERR_TIMEOUT) { + timeout_count++; + if (timeout_count >= BANDIT_OTA_MAX_RECV_TIMEOUTS) { + httpd_resp_set_status(req, "408 Request Timeout"); + return httpd_resp_sendstr(req, "Wi-Fi form receive timed out"); + } continue; } if (received <= 0) { @@ -244,6 +251,7 @@ static esp_err_t wifi_config_post_handler(httpd_req_t *req) return ESP_FAIL; } + timeout_count = 0; offset += (size_t)received; remaining -= (size_t)received; } @@ -335,6 +343,7 @@ static esp_err_t update_post_handler(httpd_req_t *req) size_t remaining = req->content_len; size_t received_total = 0; int last_percent = -1; + unsigned timeout_count = 0; while (remaining > 0) { size_t to_read = remaining < BANDIT_OTA_RECV_BUFFER @@ -343,6 +352,15 @@ static esp_err_t update_post_handler(httpd_req_t *req) int received = httpd_req_recv(req, buffer, to_read); if (received == HTTPD_SOCK_ERR_TIMEOUT) { + timeout_count++; + if (timeout_count >= BANDIT_OTA_MAX_RECV_TIMEOUTS) { + ESP_LOGE(TAG, "firmware upload timed out"); + esp_ota_abort(ota_handle); + free(buffer); + bandit_ui_set_ota_progress(0, "UPLOAD TIMEOUT"); + httpd_resp_set_status(req, "408 Request Timeout"); + return httpd_resp_sendstr(req, "Firmware upload timed out"); + } continue; } @@ -355,6 +373,8 @@ static esp_err_t update_post_handler(httpd_req_t *req) return ESP_FAIL; } + timeout_count = 0; + ret = esp_ota_write(ota_handle, buffer, received); if (ret != ESP_OK) { ESP_LOGE(TAG, "esp_ota_write failed: %s", esp_err_to_name(ret)); From fadfe2658a459ecd75699f8cdf8d1191e81fc6a7 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:18:38 -0400 Subject: [PATCH 30/48] hardening: release Wi-Fi scan results on failure paths --- main/bandit_scan.c | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/main/bandit_scan.c b/main/bandit_scan.c index 089ad8e..2114abd 100644 --- a/main/bandit_scan.c +++ b/main/bandit_scan.c @@ -136,23 +136,30 @@ esp_err_t bandit_scan_once( ESP_RETURN_ON_ERROR(esp_wifi_scan_start(&scan_cfg, true), TAG, "scan failed"); uint16_t ap_count = 0; - ESP_RETURN_ON_ERROR(esp_wifi_scan_get_ap_num(&ap_count), TAG, "AP count failed"); + esp_err_t ret = esp_wifi_scan_get_ap_num(&ap_count); + if (ret != ESP_OK) { + (void)esp_wifi_clear_ap_list(); + return ret; + } snapshot->generation = ++s_generation; snapshot->total = ap_count; if (ap_count == 0) { + (void)esp_wifi_clear_ap_list(); return ESP_OK; } wifi_ap_record_t *records = calloc(ap_count, sizeof(*records)); if (!records) { + (void)esp_wifi_clear_ap_list(); return ESP_ERR_NO_MEM; } uint16_t record_count = ap_count; - esp_err_t ret = esp_wifi_scan_get_ap_records(&record_count, records); + ret = esp_wifi_scan_get_ap_records(&record_count, records); if (ret != ESP_OK) { + (void)esp_wifi_clear_ap_list(); free(records); return ret; } From 70d2c094b0108f77caf64fcd4748cea8a765bc70 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:18:54 -0400 Subject: [PATCH 31/48] hardening: bound uplink connect and browser transfer waits --- main/bandit_uplink.c | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/main/bandit_uplink.c b/main/bandit_uplink.c index a2f7eda..363af9a 100644 --- a/main/bandit_uplink.c +++ b/main/bandit_uplink.c @@ -29,7 +29,7 @@ #define BANDIT_UPLINK_NVS_SSID "ssid" #define BANDIT_UPLINK_NVS_PASSWORD "password" #define BANDIT_UPLINK_RETRY_MS 15000 -#define BANDIT_UPLINK_CONNECT_TIMEOUT_MS 10000 +#define BANDIT_UPLINK_CONNECT_TIMEOUT_MS 3000 #define BANDIT_UPLINK_DOWNLOAD_MAX_MS 30000 #define BANDIT_UPLINK_HTTP_STACK 8192 #define BANDIT_UPLINK_AP_CACHE_MAX 64 @@ -128,7 +128,8 @@ static const char s_status_page[] = "s.textContent='Session list temporarily unavailable';box.appendChild(s);}}" "async function downloadSession(x,b){if(transferBusy)return;transferBusy=true;b.disabled=true;" "const total=x.bytes||0;const totalKiB=Math.max(1,Math.round(total/1024));" - "try{b.textContent=x.name+' \\u00b7 starting...';const r=await fetch('/download?session='+x.index,{cache:'no-store'});" + "const ctl=new AbortController(),kill=setTimeout(()=>ctl.abort(),30000);" + "try{b.textContent=x.name+' \\u00b7 starting...';const r=await fetch('/download?session='+x.index,{cache:'no-store',signal:ctl.signal});" "if(!r.ok)throw new Error('HTTP '+r.status);let blob;" "if(r.body&&r.body.getReader){const rd=r.body.getReader(),parts=[];let got=0;" "while(true){const q=await rd.read();if(q.done)break;parts.push(q.value);got+=q.value.byteLength;" @@ -138,7 +139,7 @@ static const char s_status_page[] = "const u=URL.createObjectURL(blob),a=document.createElement('a');a.href=u;a.download=x.name;document.body.appendChild(a);a.click();a.remove();" "setTimeout(()=>URL.revokeObjectURL(u),5000);b.textContent=x.name+' \\u00b7 downloaded';" "}catch(e){b.textContent=x.name+' \\u00b7 retry';}" - "finally{transferBusy=false;b.disabled=false;setTimeout(()=>{tick();refreshAps();refreshLogs();},250);}}" + "finally{clearTimeout(kill);transferBusy=false;b.disabled=false;setTimeout(()=>{tick();refreshAps();refreshLogs();},250);}}" "tick();refreshAps();refreshLogs();setInterval(tick,2000);setInterval(refreshAps,2000);setInterval(refreshLogs,10000);" ""; From e4cda2dd7622c4d1e2c401d49e6f8af98dfb7bf7 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:20:30 -0400 Subject: [PATCH 32/48] hardening: keep critical-section state copies trivial --- main/bandit_uplink.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main/bandit_uplink.c b/main/bandit_uplink.c index 363af9a..5a32a49 100644 --- a/main/bandit_uplink.c +++ b/main/bandit_uplink.c @@ -215,8 +215,8 @@ static esp_err_t status_json_handler(httpd_req_t *req) snapshot = s_snapshot; have_snapshot = s_have_snapshot; connected = s_connected; - snprintf(ssid, sizeof(ssid), "%s", s_ssid); - snprintf(ip, sizeof(ip), "%s", s_ip); + memcpy(ssid, s_ssid, sizeof(ssid)); + memcpy(ip, s_ip, sizeof(ip)); portEXIT_CRITICAL(&s_state_mux); char escaped_ssid[70]; From 83f4714020fff5b13721ca59d018c8f0243a869b Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:20:56 -0400 Subject: [PATCH 33/48] hardening: report OTA confirmation result --- main/bandit_ota.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/bandit_ota.h b/main/bandit_ota.h index 1bea6c8..ddd85b4 100644 --- a/main/bandit_ota.h +++ b/main/bandit_ota.h @@ -12,5 +12,5 @@ esp_err_t bandit_ota_button_init(void); bool bandit_ota_requested(void); esp_err_t bandit_ota_start(void); -void bandit_ota_confirm_running_image(void); +bool bandit_ota_confirm_running_image(void); void bandit_ota_rollback_if_pending(void); From 5288579cebc00cab4da12db44a7930c28c43b008 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:21:00 -0400 Subject: [PATCH 34/48] hardening: retry failed OTA confirmation --- main/bandit_ota.c | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/main/bandit_ota.c b/main/bandit_ota.c index d9286e1..397e954 100644 --- a/main/bandit_ota.c +++ b/main/bandit_ota.c @@ -589,26 +589,30 @@ esp_err_t bandit_ota_start(void) return ESP_OK; } -void bandit_ota_confirm_running_image(void) +bool bandit_ota_confirm_running_image(void) { const esp_partition_t *running = esp_ota_get_running_partition(); if (!running) { - return; + return false; } esp_ota_img_states_t state; if (esp_ota_get_state_partition(running, &state) != ESP_OK) { - return; + return false; } - if (state == ESP_OTA_IMG_PENDING_VERIFY) { - esp_err_t ret = esp_ota_mark_app_valid_cancel_rollback(); - if (ret == ESP_OK) { - ESP_LOGI(TAG, "confirmed OTA image %s as valid", running->label); - } else { - ESP_LOGE(TAG, "failed to confirm OTA image: %s", esp_err_to_name(ret)); - } + if (state != ESP_OTA_IMG_PENDING_VERIFY) { + return true; + } + + esp_err_t ret = esp_ota_mark_app_valid_cancel_rollback(); + if (ret == ESP_OK) { + ESP_LOGI(TAG, "confirmed OTA image %s as valid", running->label); + return true; } + + ESP_LOGE(TAG, "failed to confirm OTA image: %s", esp_err_to_name(ret)); + return false; } void bandit_ota_rollback_if_pending(void) From e022480ea97a4faccce8e54edf1bc75e315ba56a Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:21:03 -0400 Subject: [PATCH 35/48] hardening: only latch successful OTA confirmation --- main/app_main.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/main/app_main.c b/main/app_main.c index 75e75e9..1d81413 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -150,8 +150,7 @@ static void scan_task(void *arg) first_scan_ok && bandit_uplink_first_attempt_resolved() && (esp_timer_get_time() / 1000) - healthy_since_ms >= OTA_CONFIRM_HEALTH_MS) { - bandit_ota_confirm_running_image(); - ota_confirmed = true; + ota_confirmed = bandit_ota_confirm_running_image(); } if (bandit_ota_requested()) { From 09916055ec4edb322ac17ec1364b90e16f50d839 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:22:03 -0400 Subject: [PATCH 36/48] hardening: identify v0.4.4 release candidate --- main/bandit_version.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main/bandit_version.h b/main/bandit_version.h index 6938853..fbb4848 100644 --- a/main/bandit_version.h +++ b/main/bandit_version.h @@ -1,4 +1,4 @@ #pragma once -#define BANDIT_VERSION "v0.4.3 WEB DL FETCH" -#define BANDIT_VERSION_SHORT "v0.4.3" +#define BANDIT_VERSION "v0.4.4 HARDENING RC" +#define BANDIT_VERSION_SHORT "v0.4.4" From 462f8f043fe3509a3b34969796ce6d0425609ee5 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:22:06 -0400 Subject: [PATCH 37/48] docs: record adversarial hardening pass --- README.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/README.md b/README.md index 137c62f..02de99b 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,70 @@ manually framed response. The branch `release/v0.3.1` points at the exact physically validated v0.3.1 mainline commit. The connected GitHub tooling used for this project can mutate branches/PRs but does not expose Git tag/release-asset creation, so this branch is the durable recovery pointer until a GitHub Release/tag is created separately. +## v0.4.4 hardening RC + +This release-candidate pass follows an adversarial whole-repository review and +focuses on concurrency, recovery, and long-run reliability rather than adding +new user-facing features. + +### TF/FAT lifetime safety + +The storage module now owns a mutex and explicit completed-session reader leases. + +- storage state becomes unavailable before unmount begins +- FAT/VFS unmount is deferred while any web reader is still open +- no new completed-session reader can open after teardown starts +- web downloads use storage-owned open/read/close wrappers instead of touching + FATFS directly +- session listing is serialized with teardown and stats only the newest retained + entries +- session allocation scans the directory once for the highest canonical session + number instead of probing every number with repeated `stat()` +- session names must exactly match `session-NNNN.csv` +- failed initial session creation removes the incomplete file +- CSV cells beginning with spreadsheet formula trigger characters are prefixed + to prevent formula execution when logs are opened in spreadsheet software + +### Scout/uplink arbitration + +Radio ownership is now nonblocking from the scout control loop. + +- scans use a try-lock instead of waiting forever behind a browser transfer +- the control loop continues servicing BOOT/OTA, storage recovery, and uplink + state while the radio is busy +- uplink connection attempts have an explicit connecting state and a 3-second + bounded opportunity before the radio returns to scouting +- scan-start `ESP_ERR_WIFI_STATE` is treated as a deferral rather than a hard + scan error +- DHCP lost-IP events are handled +- Wi-Fi configuration uses RAM storage; the BandiC5 NVS namespace remains the + credential source of truth + +### HTTP reliability + +- status server enables LRU socket purge and TCP keepalive +- completed CSV transfers are bounded to 30 seconds server-side +- browser-side fetches are also aborted after 30 seconds +- normal page polling pauses during a CSV transfer +- downloads retain the radio guard but can no longer park the main control loop + +### OTA and event-task hardening + +- HTTP and mDNS startup moved out of the ESP-IDF system event task +- Wi-Fi/IP event callbacks now only update small state flags +- update and Wi-Fi-config POST receives abort after six consecutive socket + timeouts instead of wedging update mode indefinitely +- pending OTA images are no longer confirmed immediately after task creation +- confirmation waits for a successful scan/publish path, a 30-second healthy + runtime window, and resolution of the first uplink attempt when configured +- failed confirmation is retried rather than latched as successful + +### Scan failure cleanup + +Wi-Fi driver AP lists are explicitly cleared on count/allocation/retrieval +failure paths so a dense-environment allocation failure does not retain the +driver's scan result memory. + ## Build The project targets **ESP-IDF v5.5.5** and **esp32c5**. From 10ded4d760dea946130535fcf172c01d8d899b5a Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:27:13 -0400 Subject: [PATCH 38/48] fix: include Wi-Fi error definitions in control loop --- main/app_main.c | 1 + 1 file changed, 1 insertion(+) diff --git a/main/app_main.c b/main/app_main.c index 1d81413..0c7abdb 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -3,6 +3,7 @@ #include "esp_log.h" #include "esp_timer.h" +#include "esp_wifi.h" #include "esp_system.h" #include "bandit_ota.h" From b2b845f2d1e8135c30a69a165284391ba8224c2e Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:57:38 -0400 Subject: [PATCH 39/48] hardening: create session files exclusively --- main/bandit_storage.c | 74 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 10 deletions(-) diff --git a/main/bandit_storage.c b/main/bandit_storage.c index 003a52a..a4f444e 100644 --- a/main/bandit_storage.c +++ b/main/bandit_storage.c @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -189,8 +190,12 @@ esp_err_t bandit_storage_session_path( return ESP_OK; } -static esp_err_t choose_session_path_locked(void) +static esp_err_t choose_session_start_index_locked(unsigned *next_index) { + if (!next_index) { + return ESP_ERR_INVALID_ARG; + } + DIR *dir = opendir(BANDIT_LOG_DIR); if (!dir) { ESP_LOGE(TAG, "opendir %s failed: %s", BANDIT_LOG_DIR, strerror(errno)); @@ -198,25 +203,32 @@ static esp_err_t choose_session_path_locked(void) } unsigned highest = 0; + errno = 0; + struct dirent *entry; while ((entry = readdir(dir)) != NULL) { unsigned index = 0; if (parse_session_filename(entry->d_name, &index) && index > highest) { highest = index; } + errno = 0; } + + int readdir_errno = errno; closedir(dir); + if (readdir_errno != 0) { + ESP_LOGE(TAG, "readdir %s failed: %s", BANDIT_LOG_DIR, strerror(readdir_errno)); + return ESP_FAIL; + } + if (highest >= BANDIT_SESSION_LIMIT) { ESP_LOGE(TAG, "session namespace exhausted"); return ESP_ERR_NO_MEM; } - return bandit_storage_session_path( - highest + 1U, - s_session_path, - sizeof(s_session_path) - ); + *next_index = highest + 1U; + return ESP_OK; } static bool csv_formula_prefix_needed(const char *text) @@ -293,19 +305,61 @@ static esp_err_t start_session_locked(void) return ret; } - ret = choose_session_path_locked(); + unsigned session_index = 0; + ret = choose_session_start_index_locked(&session_index); if (ret != ESP_OK) { teardown_storage_locked(BANDIT_STORAGE_ERROR); schedule_retry_locked(); return ret; } - s_log_file = fopen(s_session_path, "wb"); + for (; session_index <= BANDIT_SESSION_LIMIT; session_index++) { + ret = bandit_storage_session_path( + session_index, + s_session_path, + sizeof(s_session_path) + ); + if (ret != ESP_OK) { + teardown_storage_locked(BANDIT_STORAGE_ERROR); + schedule_retry_locked(); + return ret; + } + + int fd = open( + s_session_path, + O_WRONLY | O_CREAT | O_EXCL, + 0664 + ); + if (fd < 0) { + if (errno == EEXIST) { + continue; + } + + ESP_LOGE(TAG, "create %s failed: %s", s_session_path, strerror(errno)); + teardown_storage_locked(BANDIT_STORAGE_ERROR); + schedule_retry_locked(); + return ESP_FAIL; + } + + s_log_file = fdopen(fd, "w"); + if (!s_log_file) { + int fdopen_errno = errno; + (void)close(fd); + (void)unlink(s_session_path); + ESP_LOGE(TAG, "fdopen %s failed: %s", s_session_path, strerror(fdopen_errno)); + teardown_storage_locked(BANDIT_STORAGE_ERROR); + schedule_retry_locked(); + return ESP_FAIL; + } + + break; + } + if (!s_log_file) { - ESP_LOGE(TAG, "open %s failed: %s", s_session_path, strerror(errno)); + ESP_LOGE(TAG, "session namespace exhausted"); teardown_storage_locked(BANDIT_STORAGE_ERROR); schedule_retry_locked(); - return ESP_FAIL; + return ESP_ERR_NO_MEM; } if (fputs( From 5b02d9977886742b35af314de353630ef6c6cbf1 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:59:25 -0400 Subject: [PATCH 40/48] hardening: fix connect timing and progress-based downloads --- main/bandit_uplink.c | 206 +++++++++++++++++++++++++++++++++---------- 1 file changed, 159 insertions(+), 47 deletions(-) diff --git a/main/bandit_uplink.c b/main/bandit_uplink.c index 5a32a49..3a3f041 100644 --- a/main/bandit_uplink.c +++ b/main/bandit_uplink.c @@ -29,8 +29,8 @@ #define BANDIT_UPLINK_NVS_SSID "ssid" #define BANDIT_UPLINK_NVS_PASSWORD "password" #define BANDIT_UPLINK_RETRY_MS 15000 -#define BANDIT_UPLINK_CONNECT_TIMEOUT_MS 3000 -#define BANDIT_UPLINK_DOWNLOAD_MAX_MS 30000 +#define BANDIT_UPLINK_ASSOC_TIMEOUT_MS 12000 +#define BANDIT_UPLINK_DHCP_TIMEOUT_MS 10000 #define BANDIT_UPLINK_HTTP_STACK 8192 #define BANDIT_UPLINK_AP_CACHE_MAX 64 #define BANDIT_UPLINK_SESSION_LIST_MAX 32 @@ -41,14 +41,17 @@ static const char *TAG = "bandit_uplink"; static volatile bool s_enabled; static bool s_connected; static bool s_connecting; +static bool s_associated; static bool s_first_attempt_resolved; static bool s_services_pending; static bool s_state_dirty; +static bool s_scan_waiting; static char s_ssid[33]; static char s_ip[16]; static httpd_handle_t s_server; static int64_t s_next_retry_ms; static int64_t s_connect_deadline_ms; +static bool s_connect_radio_owned; static esp_event_handler_instance_t s_wifi_handler; static esp_event_handler_instance_t s_ip_handler; static bool s_wifi_handler_registered; @@ -128,13 +131,13 @@ static const char s_status_page[] = "s.textContent='Session list temporarily unavailable';box.appendChild(s);}}" "async function downloadSession(x,b){if(transferBusy)return;transferBusy=true;b.disabled=true;" "const total=x.bytes||0;const totalKiB=Math.max(1,Math.round(total/1024));" - "const ctl=new AbortController(),kill=setTimeout(()=>ctl.abort(),30000);" + "const ctl=new AbortController();let kill=0;const arm=()=>{clearTimeout(kill);kill=setTimeout(()=>ctl.abort(),30000);};arm();" "try{b.textContent=x.name+' \\u00b7 starting...';const r=await fetch('/download?session='+x.index,{cache:'no-store',signal:ctl.signal});" "if(!r.ok)throw new Error('HTTP '+r.status);let blob;" "if(r.body&&r.body.getReader){const rd=r.body.getReader(),parts=[];let got=0;" - "while(true){const q=await rd.read();if(q.done)break;parts.push(q.value);got+=q.value.byteLength;" + "while(true){const q=await rd.read();if(q.done)break;arm();parts.push(q.value);got+=q.value.byteLength;" "b.textContent=x.name+' \\u00b7 '+Math.max(1,Math.round(got/1024))+'/'+totalKiB+' KiB';}" - "blob=new Blob(parts,{type:'text/csv;charset=utf-8'});}else{blob=await r.blob();}" + "blob=new Blob(parts,{type:'text/csv;charset=utf-8'});}else{clearTimeout(kill);blob=await r.blob();}" "if(total&&blob.size!==total)throw new Error('size mismatch');" "const u=URL.createObjectURL(blob),a=document.createElement('a');a.href=u;a.download=x.name;document.body.appendChild(a);a.click();a.remove();" "setTimeout(()=>URL.revokeObjectURL(u),5000);b.textContent=x.name+' \\u00b7 downloaded';" @@ -478,19 +481,6 @@ static esp_err_t download_handler(httpd_req_t *req) return ESP_ERR_INVALID_ARG; } - char *buffer = malloc(BANDIT_UPLINK_DOWNLOAD_BUFFER); - if (!buffer) { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory"); - return ESP_ERR_NO_MEM; - } - - if (s_radio_mutex && - xSemaphoreTake(s_radio_mutex, pdMS_TO_TICKS(15000)) != pdTRUE) { - free(buffer); - httpd_resp_set_status(req, "503 Service Unavailable"); - return httpd_resp_sendstr(req, "Radio busy; try the download again"); - } - bandit_storage_reader_t *reader = NULL; size_t file_size = 0; esp_err_t ret = bandit_storage_open_completed_session( @@ -499,11 +489,6 @@ static esp_err_t download_handler(httpd_req_t *req) &file_size ); if (ret != ESP_OK) { - if (s_radio_mutex) { - xSemaphoreGive(s_radio_mutex); - } - free(buffer); - if (ret == ESP_ERR_NOT_FOUND) { httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Session not found"); } else if (ret == ESP_ERR_INVALID_STATE) { @@ -515,6 +500,53 @@ static esp_err_t download_handler(httpd_req_t *req) return ret; } + char *buffer = malloc(BANDIT_UPLINK_DOWNLOAD_BUFFER); + if (!buffer) { + bandit_storage_close_completed_session(reader); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory"); + return ESP_ERR_NO_MEM; + } + + bool connecting; + bool connected; + bool scan_waiting; + portENTER_CRITICAL(&s_state_mux); + connecting = s_connecting; + connected = s_connected; + scan_waiting = s_scan_waiting; + portEXIT_CRITICAL(&s_state_mux); + + if ((connecting && !connected) || scan_waiting) { + bandit_storage_close_completed_session(reader); + free(buffer); + httpd_resp_set_status(req, "503 Service Unavailable"); + return httpd_resp_sendstr(req, "Scout radio busy; retry shortly"); + } + + if (s_radio_mutex && + xSemaphoreTake(s_radio_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) { + bandit_storage_close_completed_session(reader); + free(buffer); + httpd_resp_set_status(req, "503 Service Unavailable"); + return httpd_resp_sendstr(req, "Radio busy; retry shortly"); + } + + portENTER_CRITICAL(&s_state_mux); + connecting = s_connecting; + connected = s_connected; + scan_waiting = s_scan_waiting; + portEXIT_CRITICAL(&s_state_mux); + + if ((connecting && !connected) || scan_waiting) { + if (s_radio_mutex) { + xSemaphoreGive(s_radio_mutex); + } + bandit_storage_close_completed_session(reader); + free(buffer); + httpd_resp_set_status(req, "503 Service Unavailable"); + return httpd_resp_sendstr(req, "Scout scan pending; retry shortly"); + } + char disposition[64]; char size_header[24]; snprintf( @@ -530,17 +562,8 @@ static esp_err_t download_handler(httpd_req_t *req) httpd_resp_set_hdr(req, "Cache-Control", "no-store"); httpd_resp_set_hdr(req, "X-BandiC5-File-Size", size_header); - const int64_t started_ms = esp_timer_get_time() / 1000; size_t total_sent = 0; - while (total_sent < file_size) { - if ((esp_timer_get_time() / 1000) - started_ms > - BANDIT_UPLINK_DOWNLOAD_MAX_MS) { - ESP_LOGW(TAG, "download timed out: session=%04lu", parsed); - ret = ESP_ERR_TIMEOUT; - break; - } - size_t remaining = file_size - total_sent; size_t request = remaining < BANDIT_UPLINK_DOWNLOAD_BUFFER ? remaining @@ -682,15 +705,29 @@ static void wifi_event_handler( (void)event_base; (void)event_data; - if (!s_enabled || event_id != WIFI_EVENT_STA_DISCONNECTED) { + if (!s_enabled) { return; } const int64_t current_ms = esp_timer_get_time() / 1000; + if (event_id == WIFI_EVENT_STA_CONNECTED) { + portENTER_CRITICAL(&s_state_mux); + s_associated = true; + s_connecting = true; + s_connect_deadline_ms = current_ms + BANDIT_UPLINK_DHCP_TIMEOUT_MS; + portEXIT_CRITICAL(&s_state_mux); + return; + } + + if (event_id != WIFI_EVENT_STA_DISCONNECTED) { + return; + } + portENTER_CRITICAL(&s_state_mux); s_connected = false; s_connecting = false; + s_associated = false; s_first_attempt_resolved = true; s_services_pending = false; s_state_dirty = true; @@ -721,10 +758,11 @@ static void ip_event_handler( portENTER_CRITICAL(&s_state_mux); s_connected = true; s_connecting = false; + s_associated = true; s_first_attempt_resolved = true; s_services_pending = true; s_state_dirty = true; - snprintf(s_ip, sizeof(s_ip), "%s", ip); + memcpy(s_ip, ip, sizeof(s_ip)); portEXIT_CRITICAL(&s_state_mux); return; } @@ -734,12 +772,13 @@ static void ip_event_handler( portENTER_CRITICAL(&s_state_mux); s_connected = false; - s_connecting = false; + s_connecting = true; + s_associated = true; s_first_attempt_resolved = true; s_services_pending = false; s_state_dirty = true; s_ip[0] = '\0'; - s_next_retry_ms = current_ms + BANDIT_UPLINK_RETRY_MS; + s_connect_deadline_ms = current_ms + BANDIT_UPLINK_DHCP_TIMEOUT_MS; portEXIT_CRITICAL(&s_state_mux); } } @@ -882,7 +921,7 @@ esp_err_t bandit_uplink_init(void) ret = esp_event_handler_instance_register( WIFI_EVENT, - WIFI_EVENT_STA_DISCONNECTED, + ESP_EVENT_ANY_ID, wifi_event_handler, NULL, &s_wifi_handler @@ -902,7 +941,7 @@ esp_err_t bandit_uplink_init(void) if (ret != ESP_OK) { esp_event_handler_instance_unregister( WIFI_EVENT, - WIFI_EVENT_STA_DISCONNECTED, + ESP_EVENT_ANY_ID, s_wifi_handler ); s_wifi_handler_registered = false; @@ -924,9 +963,11 @@ esp_err_t bandit_uplink_init(void) s_ip[0] = '\0'; s_connected = false; s_connecting = false; + s_associated = false; s_first_attempt_resolved = false; s_services_pending = false; s_state_dirty = true; + s_scan_waiting = false; s_next_retry_ms = 0; s_connect_deadline_ms = 0; portEXIT_CRITICAL(&s_state_mux); @@ -948,8 +989,10 @@ void bandit_uplink_service(void) const int64_t current_ms = esp_timer_get_time() / 1000; bool connected; bool connecting; + bool associated; bool state_dirty; bool services_pending; + bool scan_waiting; int64_t next_retry_ms; int64_t connect_deadline_ms; char ssid[33]; @@ -958,8 +1001,10 @@ void bandit_uplink_service(void) portENTER_CRITICAL(&s_state_mux); connected = s_connected; connecting = s_connecting; + associated = s_associated; state_dirty = s_state_dirty; services_pending = s_services_pending; + scan_waiting = s_scan_waiting; next_retry_ms = s_next_retry_ms; connect_deadline_ms = s_connect_deadline_ms; memcpy(ssid, s_ssid, sizeof(ssid)); @@ -968,12 +1013,19 @@ void bandit_uplink_service(void) s_services_pending = false; portEXIT_CRITICAL(&s_state_mux); + if (s_connect_radio_owned && (!connecting || connected)) { + xSemaphoreGive(s_radio_mutex); + s_connect_radio_owned = false; + } + if (state_dirty) { bandit_ui_set_uplink_state(true, connected); bandit_ui_set_uplink_address(connected ? ip : NULL); if (connected) { ESP_LOGI(TAG, "uplink connected: SSID=%s IP=%s hostname=bandic5", ssid, ip); + } else if (connecting) { + ESP_LOGI(TAG, "uplink connection in progress"); } else { ESP_LOGW(TAG, "uplink disconnected; reconnect scheduled between scans"); } @@ -1006,19 +1058,37 @@ void bandit_uplink_service(void) } if (connecting) { + if (!s_connect_radio_owned) { + if (!s_radio_mutex || + xSemaphoreTake(s_radio_mutex, 0) != pdTRUE) { + return; + } + s_connect_radio_owned = true; + } + if (current_ms < connect_deadline_ms) { return; } - ESP_LOGW(TAG, "uplink connect attempt timed out; returning radio to scout"); + ESP_LOGW( + TAG, + "uplink %s timed out; returning radio to scout", + associated ? "DHCP" : "association" + ); (void)esp_wifi_disconnect(); portENTER_CRITICAL(&s_state_mux); s_connecting = false; + s_associated = false; s_first_attempt_resolved = true; s_state_dirty = true; s_next_retry_ms = current_ms + BANDIT_UPLINK_RETRY_MS; portEXIT_CRITICAL(&s_state_mux); + + if (s_connect_radio_owned) { + xSemaphoreGive(s_radio_mutex); + s_connect_radio_owned = false; + } return; } @@ -1026,21 +1096,42 @@ void bandit_uplink_service(void) return; } + // Once a scan has been deferred, let it run before beginning another + // connection attempt. + if (scan_waiting) { + return; + } + + if (!s_radio_mutex || + xSemaphoreTake(s_radio_mutex, 0) != pdTRUE) { + return; + } + s_connect_radio_owned = true; + + // Publish CONNECTING before calling into the asynchronous Wi-Fi driver so a + // very fast GOT_IP event cannot be overwritten by the caller afterwards. + portENTER_CRITICAL(&s_state_mux); + s_connecting = true; + s_associated = false; + s_connect_deadline_ms = current_ms + BANDIT_UPLINK_ASSOC_TIMEOUT_MS; + portEXIT_CRITICAL(&s_state_mux); + esp_err_t ret = esp_wifi_connect(); if (ret == ESP_OK || ret == ESP_ERR_WIFI_STATE) { - portENTER_CRITICAL(&s_state_mux); - s_connecting = true; - s_connect_deadline_ms = current_ms + BANDIT_UPLINK_CONNECT_TIMEOUT_MS; - portEXIT_CRITICAL(&s_state_mux); return; } ESP_LOGD(TAG, "uplink connect attempt failed immediately: %s", esp_err_to_name(ret)); + portENTER_CRITICAL(&s_state_mux); s_connecting = false; + s_associated = false; s_first_attempt_resolved = true; s_next_retry_ms = current_ms + BANDIT_UPLINK_RETRY_MS; portEXIT_CRITICAL(&s_state_mux); + + xSemaphoreGive(s_radio_mutex); + s_connect_radio_owned = false; } void bandit_uplink_stop(void) @@ -1050,7 +1141,7 @@ void bandit_uplink_stop(void) if (s_wifi_handler_registered) { esp_event_handler_instance_unregister( WIFI_EVENT, - WIFI_EVENT_STA_DISCONNECTED, + ESP_EVENT_ANY_ID, s_wifi_handler ); s_wifi_handler_registered = false; @@ -1065,6 +1156,11 @@ void bandit_uplink_stop(void) s_ip_handler_registered = false; } + if (s_connect_radio_owned && s_radio_mutex) { + xSemaphoreGive(s_radio_mutex); + s_connect_radio_owned = false; + } + if (s_server) { httpd_stop(s_server); s_server = NULL; @@ -1081,9 +1177,11 @@ void bandit_uplink_stop(void) portENTER_CRITICAL(&s_state_mux); s_connected = false; s_connecting = false; + s_associated = false; s_first_attempt_resolved = true; s_services_pending = false; s_state_dirty = false; + s_scan_waiting = false; s_next_retry_ms = 0; s_connect_deadline_ms = 0; s_ip[0] = '\0'; @@ -1093,28 +1191,43 @@ void bandit_uplink_stop(void) bool bandit_uplink_try_begin_scan(void) { bool connecting = false; + bool connected = false; portENTER_CRITICAL(&s_state_mux); connecting = s_connecting; + connected = s_connected; portEXIT_CRITICAL(&s_state_mux); - if (connecting) { + if (connecting && !connected) { + portENTER_CRITICAL(&s_state_mux); + s_scan_waiting = true; + portEXIT_CRITICAL(&s_state_mux); return false; } if (s_radio_mutex && xSemaphoreTake(s_radio_mutex, 0) != pdTRUE) { + portENTER_CRITICAL(&s_state_mux); + s_scan_waiting = true; + portEXIT_CRITICAL(&s_state_mux); return false; } portENTER_CRITICAL(&s_state_mux); connecting = s_connecting; + connected = s_connected; + if (!(connecting && !connected)) { + s_scan_waiting = false; + } portEXIT_CRITICAL(&s_state_mux); - if (connecting) { + if (connecting && !connected) { if (s_radio_mutex) { xSemaphoreGive(s_radio_mutex); } + portENTER_CRITICAL(&s_state_mux); + s_scan_waiting = true; + portEXIT_CRITICAL(&s_state_mux); return false; } @@ -1130,7 +1243,6 @@ void bandit_uplink_end_scan(void) } } - bool bandit_uplink_first_attempt_resolved(void) { portENTER_CRITICAL(&s_state_mux); From 38aaf57e334074e5997dd36c62b2853706d20535 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:59:40 -0400 Subject: [PATCH 41/48] hardening: confirm pending image after recovery server starts --- main/app_main.c | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/main/app_main.c b/main/app_main.c index 0c7abdb..41559ff 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -51,6 +51,16 @@ static void enter_ota_mode(void) esp_restart(); } + // A live updater AP + HTTP server is the recovery path rollback is meant + // to preserve. Confirm a pending image here so update-mode uploads and a + // Wi-Fi-settings reboot cannot accidentally roll this working image back. + if (!bandit_ota_confirm_running_image()) { + ESP_LOGE(TAG, "unable to confirm running image after OTA recovery started"); + bandit_ui_set_ota_progress(0, "OTA VERIFY FAILED"); + vTaskDelay(pdMS_TO_TICKS(2500)); + esp_restart(); + } + // The HTTP server owns the updater from here. This task is no longer needed. vTaskDelete(NULL); } From 431804bab0c726a89262fe47c02b38eeff9a2f04 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:00:47 -0400 Subject: [PATCH 42/48] hardening: close late-IP connect race --- main/bandit_uplink.c | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/main/bandit_uplink.c b/main/bandit_uplink.c index 3a3f041..0665046 100644 --- a/main/bandit_uplink.c +++ b/main/bandit_uplink.c @@ -29,7 +29,7 @@ #define BANDIT_UPLINK_NVS_SSID "ssid" #define BANDIT_UPLINK_NVS_PASSWORD "password" #define BANDIT_UPLINK_RETRY_MS 15000 -#define BANDIT_UPLINK_ASSOC_TIMEOUT_MS 12000 +#define BANDIT_UPLINK_ASSOC_TIMEOUT_MS 15000 #define BANDIT_UPLINK_DHCP_TIMEOUT_MS 10000 #define BANDIT_UPLINK_HTTP_STACK 8192 #define BANDIT_UPLINK_AP_CACHE_MAX 64 @@ -732,6 +732,7 @@ static void wifi_event_handler( s_services_pending = false; s_state_dirty = true; s_ip[0] = '\0'; + s_connect_deadline_ms = 0; s_next_retry_ms = current_ms + BANDIT_UPLINK_RETRY_MS; portEXIT_CRITICAL(&s_state_mux); } @@ -762,6 +763,7 @@ static void ip_event_handler( s_first_attempt_resolved = true; s_services_pending = true; s_state_dirty = true; + s_connect_deadline_ms = 0; memcpy(s_ip, ip, sizeof(s_ip)); portEXIT_CRITICAL(&s_state_mux); return; @@ -1123,15 +1125,22 @@ void bandit_uplink_service(void) ESP_LOGD(TAG, "uplink connect attempt failed immediately: %s", esp_err_to_name(ret)); + bool connected_after_call; portENTER_CRITICAL(&s_state_mux); - s_connecting = false; - s_associated = false; - s_first_attempt_resolved = true; - s_next_retry_ms = current_ms + BANDIT_UPLINK_RETRY_MS; + connected_after_call = s_connected; + if (!connected_after_call) { + s_connecting = false; + s_associated = false; + s_first_attempt_resolved = true; + s_connect_deadline_ms = 0; + s_next_retry_ms = current_ms + BANDIT_UPLINK_RETRY_MS; + } portEXIT_CRITICAL(&s_state_mux); - xSemaphoreGive(s_radio_mutex); - s_connect_radio_owned = false; + if (!connected_after_call) { + xSemaphoreGive(s_radio_mutex); + s_connect_radio_owned = false; + } } void bandit_uplink_stop(void) From a10949c09f797a5e21a3755a4de4fd57480cc36c Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:01:35 -0400 Subject: [PATCH 43/48] hardening: identify v0.4.5 static release candidate --- main/bandit_version.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main/bandit_version.h b/main/bandit_version.h index fbb4848..8bead5b 100644 --- a/main/bandit_version.h +++ b/main/bandit_version.h @@ -1,4 +1,4 @@ #pragma once -#define BANDIT_VERSION "v0.4.4 HARDENING RC" -#define BANDIT_VERSION_SHORT "v0.4.4" +#define BANDIT_VERSION "v0.4.5 STATIC RC" +#define BANDIT_VERSION_SHORT "v0.4.5" From 83d06830046eb6f3918b95b126e71ee4a02744af Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:01:39 -0400 Subject: [PATCH 44/48] docs: record v0.4.5 static hardening fixes --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/README.md b/README.md index 02de99b..cf4b8fc 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,53 @@ manually framed response. The branch `release/v0.3.1` points at the exact physically validated v0.3.1 mainline commit. The connected GitHub tooling used for this project can mutate branches/PRs but does not expose Git tag/release-asset creation, so this branch is the durable recovery pointer until a GitHub Release/tag is created separately. +## v0.4.5 static RC + +This pass addresses the three merge blockers found by the adversarial re-review +of v0.4.4, plus several small safety fixes that were cheap to close before +hardware testing. + +### Uplink connection state + +- association/discovery now has a 15-second window instead of a 3-second total + connect deadline +- `WIFI_EVENT_STA_CONNECTED` starts a separate 10-second DHCP window +- the CONNECTING state is published before `esp_wifi_connect()`, so an + unusually fast GOT_IP event cannot be overwritten by the caller +- connect attempts now own the same radio mutex as scans and downloads for the + asynchronous attempt lifetime +- LOST_IP is treated as a DHCP-recovery state instead of immediately issuing a + second connect while still associated +- a deferred scout scan gets priority before another connect or download starts + +### Completed-session downloads + +- the 30-second total transfer cap is removed +- ESP-IDF's configured socket send timeout remains the server-side stalled-client + bound +- the browser's 30-second timer is now inactivity-based and is re-armed whenever + a stream chunk arrives +- completed sessions are validated/opened before attempting to reserve the radio +- downloads back off when a scout scan is already waiting +- the radio wait for a download is capped at one second + +### OTA rollback interaction + +A pending OTA image is confirmed after the update AP and updater HTTP server +successfully start. That recovery path is sufficient evidence to make update +mode usable: a second firmware upload and a Wi-Fi-settings reboot can no longer +be rejected or silently roll the new image back during its normal health window. + +Normal scout-mode confirmation is still delayed until a successful scan/publish, +the healthy-runtime window, and first uplink-attempt resolution. + +### Session creation safety + +- directory enumeration errors are distinguished from normal end-of-directory +- new session files are created with exclusive-create semantics +- a case-variant or otherwise previously missed filename cannot be silently + truncated by opening it with create-always behavior + ## v0.4.4 hardening RC This release-candidate pass follows an adversarial whole-repository review and From fc719cd63db87203fcf7d50b2476cfea8aaca0f9 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:03:22 -0400 Subject: [PATCH 45/48] hardening: cancel web transfer during uplink shutdown --- main/bandit_uplink.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/main/bandit_uplink.c b/main/bandit_uplink.c index 0665046..6d68439 100644 --- a/main/bandit_uplink.c +++ b/main/bandit_uplink.c @@ -564,6 +564,12 @@ static esp_err_t download_handler(httpd_req_t *req) size_t total_sent = 0; while (total_sent < file_size) { + if (!s_enabled) { + ESP_LOGI(TAG, "download cancelled because uplink is stopping"); + ret = ESP_ERR_INVALID_STATE; + break; + } + size_t remaining = file_size - total_sent; size_t request = remaining < BANDIT_UPLINK_DOWNLOAD_BUFFER ? remaining From 4b5a6fe35f80b0c7386d1a0239fd10d85b37e37e Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:03:26 -0400 Subject: [PATCH 46/48] hardening: close pending-image update-mode race --- main/bandit_ota.c | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/main/bandit_ota.c b/main/bandit_ota.c index 397e954..0a15d8c 100644 --- a/main/bandit_ota.c +++ b/main/bandit_ota.c @@ -226,6 +226,17 @@ static bool form_value( static esp_err_t wifi_config_post_handler(httpd_req_t *req) { + // If this is a freshly OTA-booted image, reaching this handler proves the + // recovery AP and HTTP server are alive. Confirm before any reboot path. + if (!bandit_ota_confirm_running_image()) { + httpd_resp_send_err( + req, + HTTPD_500_INTERNAL_SERVER_ERROR, + "Unable to confirm running image" + ); + return ESP_FAIL; + } + if (req->content_len == 0 || req->content_len >= 384) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid Wi-Fi form"); return ESP_FAIL; @@ -294,6 +305,18 @@ static esp_err_t wifi_config_post_handler(httpd_req_t *req) static esp_err_t update_post_handler(httpd_req_t *req) { + // A request can race the control task's post-start confirmation by a tiny + // window. Confirm here too so esp_ota_begin() is never attempted while the + // current image is still PENDING_VERIFY. + if (!bandit_ota_confirm_running_image()) { + httpd_resp_send_err( + req, + HTTPD_500_INTERNAL_SERVER_ERROR, + "Unable to confirm running image" + ); + return ESP_FAIL; + } + ESP_LOGI( TAG, "HTTP OTA upload start, stack high water=%u", From cc3dd504ee6f83ccadc0592a41f937f63e7e2a53 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:03:59 -0400 Subject: [PATCH 47/48] docs: note shutdown and OTA race hardening --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index cf4b8fc..d9fe401 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,8 @@ hardware testing. - completed sessions are validated/opened before attempting to reserve the radio - downloads back off when a scout scan is already waiting - the radio wait for a download is capped at one second +- an in-flight download exits at the next chunk boundary when uplink shutdown + begins, so BOOT/OTA entry is not forced to wait for a healthy long transfer ### OTA rollback interaction @@ -313,6 +315,8 @@ A pending OTA image is confirmed after the update AP and updater HTTP server successfully start. That recovery path is sufficient evidence to make update mode usable: a second firmware upload and a Wi-Fi-settings reboot can no longer be rejected or silently roll the new image back during its normal health window. +Both POST handlers also confirm defensively, closing the tiny race between the +HTTP server accepting its first request and the control task's post-start check. Normal scout-mode confirmation is still delayed until a successful scan/publish, the healthy-runtime window, and first uplink-attempt resolution. From acc37b24efde4fb1da173d1b7255e39cda93e922 Mon Sep 17 00:00:00 2001 From: Daniel Brown <30573097+danielbrownjr@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:03:56 -0400 Subject: [PATCH 48/48] Fix peak heap headroom under web observability --- main/bandit_scan.c | 52 +++++++++++++++++++------------------------ main/bandit_uplink.c | 2 +- main/bandit_version.h | 4 ++-- 3 files changed, 26 insertions(+), 32 deletions(-) diff --git a/main/bandit_scan.c b/main/bandit_scan.c index 2114abd..6478367 100644 --- a/main/bandit_scan.c +++ b/main/bandit_scan.c @@ -150,30 +150,25 @@ esp_err_t bandit_scan_once( return ESP_OK; } - wifi_ap_record_t *records = calloc(ap_count, sizeof(*records)); - if (!records) { - (void)esp_wifi_clear_ap_list(); - return ESP_ERR_NO_MEM; - } - - uint16_t record_count = ap_count; - ret = esp_wifi_scan_get_ap_records(&record_count, records); - if (ret != ESP_OK) { - (void)esp_wifi_clear_ap_list(); - free(records); - return ret; - } - const int64_t observed_ms = esp_timer_get_time() / 1000; - for (uint16_t i = 0; i < record_count; i++) { - const wifi_ap_record_t *record = &records[i]; - const bool hidden = record->ssid[0] == 0; + // Consume one driver-owned AP record at a time. ESP-IDF frees each record + // as it is fetched, avoiding a second ap_count-sized application buffer + // while the Wi-Fi driver's scan list is still resident in internal RAM. + for (uint16_t i = 0; i < ap_count; i++) { + wifi_ap_record_t record; + ret = esp_wifi_scan_get_ap_record(&record); + if (ret != ESP_OK) { + (void)esp_wifi_clear_ap_list(); + return ret; + } + + const bool hidden = record.ssid[0] == 0; char ssid[33]; - ssid_to_text(record->ssid, ssid); + ssid_to_text(record.ssid, ssid); - if (record->primary > 14) { + if (record.primary > 14) { snapshot->count_5g++; } else { snapshot->count_2g++; @@ -183,13 +178,13 @@ esp_err_t bandit_scan_once( snapshot->hidden++; } - if (record->authmode == WIFI_AUTH_OPEN) { + if (record.authmode == WIFI_AUTH_OPEN) { snapshot->open++; } - if (record->rssi > snapshot->strongest_rssi) { - snapshot->strongest_rssi = record->rssi; - snapshot->strongest_channel = record->primary; + if (record.rssi > snapshot->strongest_rssi) { + snapshot->strongest_rssi = record.rssi; + snapshot->strongest_channel = record.primary; snprintf( snapshot->strongest_ssid, sizeof(snapshot->strongest_ssid), @@ -200,7 +195,7 @@ esp_err_t bandit_scan_once( snapshot->strongest_auth, sizeof(snapshot->strongest_auth), "%s", - auth_name(record->authmode) + auth_name(record.authmode) ); } @@ -208,24 +203,23 @@ esp_err_t bandit_scan_once( bandit_scan_record_t emitted = { .generation = snapshot->generation, .uptime_ms = observed_ms, - .rssi = record->rssi, - .channel = record->primary, + .rssi = record.rssi, + .channel = record.primary, .hidden = hidden, }; - memcpy(emitted.bssid, record->bssid, sizeof(emitted.bssid)); + memcpy(emitted.bssid, record.bssid, sizeof(emitted.bssid)); snprintf(emitted.ssid, sizeof(emitted.ssid), "%s", ssid); snprintf( emitted.auth, sizeof(emitted.auth), "%s", - auth_name(record->authmode) + auth_name(record.authmode) ); record_callback(&emitted, record_callback_ctx); } } - free(records); return ESP_OK; } diff --git a/main/bandit_uplink.c b/main/bandit_uplink.c index 6d68439..83d92bc 100644 --- a/main/bandit_uplink.c +++ b/main/bandit_uplink.c @@ -31,7 +31,7 @@ #define BANDIT_UPLINK_RETRY_MS 15000 #define BANDIT_UPLINK_ASSOC_TIMEOUT_MS 15000 #define BANDIT_UPLINK_DHCP_TIMEOUT_MS 10000 -#define BANDIT_UPLINK_HTTP_STACK 8192 +#define BANDIT_UPLINK_HTTP_STACK 4096 #define BANDIT_UPLINK_AP_CACHE_MAX 64 #define BANDIT_UPLINK_SESSION_LIST_MAX 32 #define BANDIT_UPLINK_DOWNLOAD_BUFFER 2048 diff --git a/main/bandit_version.h b/main/bandit_version.h index 8bead5b..4a19b0e 100644 --- a/main/bandit_version.h +++ b/main/bandit_version.h @@ -1,4 +1,4 @@ #pragma once -#define BANDIT_VERSION "v0.4.5 STATIC RC" -#define BANDIT_VERSION_SHORT "v0.4.5" +#define BANDIT_VERSION "v0.4.6 RC" +#define BANDIT_VERSION_SHORT "v0.4.6"