diff --git a/PLANS.md b/PLANS.md index 265ad44..e668d96 100644 --- a/PLANS.md +++ b/PLANS.md @@ -2263,7 +2263,7 @@ When all criteria pass, set this milestone to `DONE` and change Milestone 006 to ## Milestone 028 — Implement the open fallback access point and captive portal -**Status:** `BLOCKED` +**Status:** `DONE` **Depends on:** Milestone 027 ### Objective @@ -2294,6 +2294,14 @@ Give users access to the game and external-network configuration through the ope When all criteria pass, set this milestone to `DONE` and change Milestone 007 to `READY`. +### Execution record + +- Date: 2026-09-01 +- Result: PASS. +- Evidence: The user completed the required physical fallback-network checks with no issues: discovery and open connection to `Battleship-open`, captive-portal redirection and direct setup-page access, responsive scanning/manual hidden-SSID entry, game HTTP/WebSocket access for players and spectators, bounded invalid-input handling, and no credential disclosure. +- Verification: Host captive-portal and network-foundation tests, browser tests/syntax checks, firmware build, and LittleFS build passed during implementation. +- Next action: Milestone 029 remains blocked and was not started. + --- ## Milestone 029 — Validate credentials, persist them, and switch without rebooting diff --git a/data/setup.css b/data/setup.css index 89793ec..d79d231 100644 --- a/data/setup.css +++ b/data/setup.css @@ -1 +1 @@ -:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #041a2b; color: #e8f7ff; } body { margin: 0; padding: max(1.25rem, env(safe-area-inset-top)) max(1.25rem, env(safe-area-inset-right)) max(1.25rem, env(safe-area-inset-bottom)) max(1.25rem, env(safe-area-inset-left)); background: radial-gradient(circle at top, #0e4563, #041a2b 58%); } main { width: min(100%, 38rem); margin: 0 auto; } .eyebrow { color: #8fdbf3; font-size: .78rem; font-weight: 800; letter-spacing: .08em; } h1 { margin: .25rem 0 .5rem; } section, form { margin-top: 1.1rem; padding: 1rem; border: 1px solid #3a7593; border-radius: .8rem; background: rgb(5 31 50 / 88%); } .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } h2 { margin: 0; font-size: 1.1rem; } .networks { display: grid; gap: .5rem; } .network { width: 100%; min-height: 2.75rem; border: 1px solid #548eaa; border-radius: .5rem; background: #123d58; color: #effaff; text-align: left; } label, input, button { display: block; width: 100%; box-sizing: border-box; } label { margin-top: .85rem; font-weight: 700; } input, button { min-height: 2.75rem; margin-top: .35rem; border: 1px solid #609bbb; border-radius: .5rem; padding: .55rem .7rem; font: inherit; } input { background: #061f33; color: #effaff; } button { background: #54c2e5; color: #062033; font-weight: 800; cursor: pointer; } button:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #ffe56a; outline-offset: 3px; } .hint, #scan-status { color: #c1ddea; font-size: .9rem; } a { color: #9ce8ff; } +:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #041a2b; color: #e8f7ff; } body { margin: 0; padding: max(1.25rem, env(safe-area-inset-top)) max(1.25rem, env(safe-area-inset-right)) max(1.25rem, env(safe-area-inset-bottom)) max(1.25rem, env(safe-area-inset-left)); background: radial-gradient(circle at top, #0e4563, #041a2b 58%); } main { width: min(100%, 38rem); margin: 0 auto; } .eyebrow { color: #8fdbf3; font-size: .78rem; font-weight: 800; letter-spacing: .08em; } h1 { margin: .25rem 0 .5rem; } section, form { margin-top: 1.1rem; padding: 1rem; border: 1px solid #3a7593; border-radius: .8rem; background: rgb(5 31 50 / 88%); } .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } h2 { margin: 0; font-size: 1.1rem; } .networks { display: grid; gap: .5rem; } .network { width: 100%; min-height: 2.75rem; border: 1px solid #548eaa; border-radius: .5rem; background: #123d58; color: #effaff; text-align: left; } label, input, button { display: block; width: 100%; box-sizing: border-box; } label { margin-top: .85rem; font-weight: 700; } input, button { min-height: 2.75rem; margin-top: .35rem; border: 1px solid #609bbb; border-radius: .5rem; padding: .55rem .7rem; font: inherit; } input { background: #061f33; color: #effaff; } button { background: #54c2e5; color: #062033; font-weight: 800; cursor: pointer; } button:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #ffe56a; outline-offset: 3px; } .hint, #scan-status, #network-status { color: #c1ddea; font-size: .9rem; } .secondary { margin-top: 1rem; background: #315a75; color: #effaff; } a { color: #9ce8ff; } diff --git a/data/setup.html b/data/setup.html index 1be267c..15c38b3 100644 --- a/data/setup.html +++ b/data/setup.html @@ -23,9 +23,11 @@ -

Пароль не отображается на этой странице. Проверка и сохранение сети будут доступны на следующем шаге настройки.

- +

Пароль не отображается после отправки. Пока идёт проверка, оставайтесь в Battleship-open.

+ + +

Открыть игру

diff --git a/data/setup.js b/data/setup.js index 019da0a..1ffba19 100644 --- a/data/setup.js +++ b/data/setup.js @@ -4,20 +4,42 @@ const ssid = document.querySelector('#ssid'); const refresh = document.querySelector('#refresh'); const form = document.querySelector('#network-form'); + const password = document.querySelector('#password'); + const networkStatus = document.querySelector('#network-status'); + const remove = document.querySelector('#delete-network'); let timer = 0; const scan = async () => { clearTimeout(timer); refresh.disabled = true; status.textContent = 'Ищем сети…'; try { - const response = await fetch('/setup/scan', { cache: 'no-store' }); - const text = await response.text(); + const response = await fetch('/api/network/scan', { cache: 'no-store' }); + const result = await response.json(); if (!response.ok) throw new Error('scan unavailable'); - if (text.trim() === 'scanning') { timer = setTimeout(scan, 700); return; } - const names = [...new Set(text.split('\n').map(value => value.trim()).filter(Boolean))]; + if (result.state === 'scanning') { timer = setTimeout(scan, 700); return; } + const names = [...new Set(result.networks || [])]; networks.replaceChildren(...names.map(name => { const button = document.createElement('button'); button.className = 'network'; button.type = 'button'; button.textContent = name; button.addEventListener('click', () => { ssid.value = name; ssid.focus(); }); return button; })); status.textContent = names.length ? 'Выберите сеть или введите её вручную.' : 'Сети не найдены. Введите скрытую сеть вручную.'; } catch (_) { status.textContent = 'Не удалось выполнить поиск. Введите сеть вручную.'; } finally { if (!timer) refresh.disabled = false; } }; + const updateStatus = async () => { + try { + const result = await fetch('/api/network/status', { cache: 'no-store' }).then(response => response.json()); + networkStatus.textContent = result.message || ''; + if (result.state === 'validating') setTimeout(updateStatus, 1000); + } catch (_) { networkStatus.textContent = 'Не удалось получить состояние сети.'; } + }; refresh.addEventListener('click', scan); - form.addEventListener('submit', event => { event.preventDefault(); status.textContent = 'Сеть выбрана. Проверка и сохранение будут доступны на следующем шаге настройки.'; document.querySelector('#password').value = ''; }); + form.addEventListener('submit', async event => { + event.preventDefault(); + try { + const response = await fetch('/api/network/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssid: ssid.value, password: password.value }) }); + const result = await response.json(); password.value = ''; networkStatus.textContent = result.message || 'Проверяем подключение…'; + if (response.ok) updateStatus(); + } catch (_) { password.value = ''; networkStatus.textContent = 'Не удалось начать проверку. Повторите попытку.'; } + }); + remove.addEventListener('click', async () => { + try { const result = await fetch('/api/network/delete', { method: 'POST' }).then(response => response.json()); networkStatus.textContent = result.message || 'Сеть удалена.'; } + catch (_) { networkStatus.textContent = 'Не удалось удалить сеть.'; } + }); + updateStatus(); })(); diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index d416473..8325dae 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -49,6 +49,10 @@ Failure (maximum 160 encoded bytes): | -------------------------- | --------------: | --------------------------------------------- | | `GET /api/info` | 128 B target | public device/slot state and names, 384 B | | `GET /api/health` | 128 B target | diagnostics without secrets, 320 B; includes reset reason | +| `GET /api/network/status` | 128 B target | unauthenticated state/message; never includes SSID/password | +| `GET /api/network/scan` | 128 B target | unauthenticated bounded scan state and up to 12 SSIDs | +| `POST /api/network/validate` | 160 B body | unauthenticated `{ssid,password}` validation; password is never returned | +| `POST /api/network/delete` | 0 B body | unauthenticated deletion of the saved profile | | `POST /api/session/join` | 192 B body | `{name,requestedRole}`; token and role, 192 B | | `POST /api/session/resume` | 96 B body | `{token}`; role and state metadata, 192 B | | `POST /api/session/leave` | 80 B body | `{token,gameId}`; releases only that session | @@ -82,6 +86,11 @@ polling returns `SESSION_INVALIDATED` with the same bounded recovery metadata. The recovery generation and `gameId` make delayed commands stale; retrying a completed recovery is harmless and cannot mutate a newly registered session. +Network configuration is deliberately unauthenticated so it works from the +open fallback AP and the regular local address. Any reachable client can change +or delete the saved profile; the open AP and local HTTP do not provide password +confidentiality. Network responses and diagnostics never include the password. + ## Role-safe state event The HTTP state response and WebSocket `state` event use this single 768-byte diff --git a/docs/RELEASE_GUIDE.md b/docs/RELEASE_GUIDE.md index cd3f537..81cf8c8 100644 --- a/docs/RELEASE_GUIDE.md +++ b/docs/RELEASE_GUIDE.md @@ -13,8 +13,9 @@ does not replace the locked rules in [MVP.md](MVP.md), the HTTP contract in `esp_littlefs` 1.20.4. - USB serial device: normally `/dev/ttyACM0`; confirm with `pio device list` before an upload. -- A local `include/wifi_config.h`, copied from `include/wifi_config.h.example`. - It is ignored by Git. Never commit the SSID or password. +- No firmware-embedded Wi-Fi credentials are required. On first boot, join the + open `Battleship-open` network and open `http://192.168.4.1/setup` if the + captive portal does not appear. ## Build and upload @@ -49,6 +50,16 @@ The response includes free/minimum heap, largest free block, connection count, rejected input count, Wi-Fi state, and reset reason. It must not contain Wi-Fi credentials or session tokens. +## Network configuration and local-security limitation + +The `/setup` screen and `/api/network/*` routes are intentionally unauthenticated +so a new device can be configured through either the fallback AP or its regular +local address. Any client that can reach the device can change or delete the +saved Wi-Fi configuration. The open AP and plain local HTTP do not protect a +submitted password from a nearby network observer. Configure the board only on +a network you trust, and do not use this feature for credentials that require +strong confidentiality. + ## MVP acceptance checklist Record the observed result, device address, browser/device model, and any diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a45a91c..8444568 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -35,6 +35,40 @@ paths: application/json: schema: $ref: '#/components/schemas/Health' + /api/network/status: + get: + summary: Read unauthenticated network configuration status without credentials + responses: + '200': { description: Network state and Russian status message. } + /api/network/scan: + get: + summary: Start or retrieve a bounded unauthenticated Wi-Fi scan + responses: + '200': { description: `scanning` or a bounded list of SSIDs; no passwords. } + '409': { $ref: '#/components/responses/Conflict' } + /api/network/validate: + post: + summary: Validate credentials and persist them only after association succeeds + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [ssid, password] + properties: + ssid: { type: string, minLength: 1, maxLength: 32 } + password: { type: string, maxLength: 63, writeOnly: true } + responses: + '200': { description: Validation started; poll network status for result. } + '400': { $ref: '#/components/responses/BadRequest' } + '409': { $ref: '#/components/responses/Conflict' } + /api/network/delete: + post: + summary: Delete the saved network profile and enter fallback mode + responses: + '200': { description: Profile deleted. } + '409': { $ref: '#/components/responses/Conflict' } /api/session/join: post: summary: Create a session and request a role diff --git a/include/network_configuration.h b/include/network_configuration.h new file mode 100644 index 0000000..5392e2f --- /dev/null +++ b/include/network_configuration.h @@ -0,0 +1,26 @@ +#ifndef NETWORK_CONFIGURATION_H +#define NETWORK_CONFIGURATION_H + +#include +#include + +#include "network_state.h" + +enum { kNetworkValidationWindowMs = 30000U, kNetworkSuccessNoticeMs = 15000U }; +typedef enum { NETWORK_CONFIGURATION_IDLE, NETWORK_CONFIGURATION_VALIDATING, NETWORK_CONFIGURATION_SUCCESS, NETWORK_CONFIGURATION_FAILED } network_configuration_state_t; +typedef struct { + network_configuration_state_t state; + network_profile_t previous_profile; + bool had_previous_profile; + uint32_t validation_deadline_ms; + uint32_t success_deadline_ms; +} network_configuration_t; + +void network_configuration_init(network_configuration_t *configuration); +bool network_configuration_begin(network_configuration_t *configuration, network_manager_t *manager, const network_profile_t *candidate, uint32_t now_ms); +bool network_configuration_validation_timed_out(const network_configuration_t *configuration, uint32_t now_ms); +network_action_t network_configuration_finish(network_configuration_t *configuration, network_manager_t *manager, bool success, uint32_t now_ms); +bool network_configuration_success_notice_expired(const network_configuration_t *configuration, uint32_t now_ms); +bool network_configuration_busy(const network_configuration_t *configuration); + +#endif diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d711c92..839bedd 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,7 +2,7 @@ # without default 'CMakeLists.txt' file. idf_component_register( - SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c" "session_manager.c" "game_lifecycle.c" "state_presenter.c" "http_api.c" "sync_service.c" "network_state.c" "network_credentials.c" "network_credential_store.c" "captive_portal.c" + SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c" "session_manager.c" "game_lifecycle.c" "state_presenter.c" "http_api.c" "sync_service.c" "network_state.c" "network_credentials.c" "network_credential_store.c" "network_configuration.c" "captive_portal.c" INCLUDE_DIRS "../include" REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash ) diff --git a/src/main.c b/src/main.c index b11a735..5d6f66d 100644 --- a/src/main.c +++ b/src/main.c @@ -20,6 +20,7 @@ #include "freertos/portmacro.h" #include "http_api.h" #include "network_credential_store.h" +#include "network_configuration.h" #include "network_state.h" #include "nvs_flash.h" #include "sync_service.h" @@ -34,6 +35,7 @@ enum { kHttpMaxOpenSockets = 12U, kFallbackMaxConnections = 10U, kDnsPort = 53U, typedef struct { bool configured; bool connected; bool fallback_active; } wifi_state_t; static portMUX_TYPE s_wifi_lock = portMUX_INITIALIZER_UNLOCKED; static portMUX_TYPE s_network_lock = portMUX_INITIALIZER_UNLOCKED; +static portMUX_TYPE s_configuration_lock = portMUX_INITIALIZER_UNLOCKED; static portMUX_TYPE s_diagnostics_lock = portMUX_INITIALIZER_UNLOCKED; static wifi_state_t s_wifi_state = {0}; static bool s_littlefs_mounted; @@ -46,10 +48,13 @@ static esp_timer_handle_t s_sync_timer; static esp_timer_handle_t s_bot_timer; static esp_timer_handle_t s_network_timer; static network_manager_t s_network_manager; +static network_configuration_t s_configuration; static bool s_scan_pending; static bool s_scan_ready; static bool s_dns_running; +enum { kNetworkRequestBytes = 160U, kNetworkResponseBytes = 768U }; + static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); } static void record_rejected_input(void) { @@ -300,6 +305,40 @@ static void disable_fallback_ap(void) { if (esp_wifi_set_mode(WIFI_MODE_STA) != ESP_OK) ESP_LOGW(kLogTag, "fallback access point stop failed"); } +static esp_err_t configure_station_profile(const network_profile_t *profile) { + wifi_config_t station_config = {0}; + if (profile != NULL) { + memcpy(station_config.sta.ssid, profile->ssid, kNetworkSsidBytes); + memcpy(station_config.sta.password, profile->password, kNetworkPasswordBytes); + } + return esp_wifi_set_config(WIFI_IF_STA, &station_config); +} + +static bool configuration_is_validating(void) { + bool validating; + portENTER_CRITICAL(&s_configuration_lock); validating = network_configuration_busy(&s_configuration); portEXIT_CRITICAL(&s_configuration_lock); + return validating; +} + +static const char *configuration_state_name(void) { + network_configuration_state_t state; + portENTER_CRITICAL(&s_configuration_lock); state = s_configuration.state; portEXIT_CRITICAL(&s_configuration_lock); + if (state == NETWORK_CONFIGURATION_VALIDATING) return "validating"; + if (state == NETWORK_CONFIGURATION_SUCCESS) return "success"; + if (state == NETWORK_CONFIGURATION_FAILED) return "failed"; + const wifi_state_t wifi = wifi_state_snapshot(); + return wifi.fallback_active ? "fallback" : wifi.connected ? "connected" : "connecting"; +} + +static const char *configuration_message(void) { + const char *state = configuration_state_name(); + if (strcmp(state, "validating") == 0) return "Проверяем подключение к сети…"; + if (strcmp(state, "success") == 0) return "Сеть сохранена. Подключите устройство к домашней сети: точка доступа скоро отключится."; + if (strcmp(state, "failed") == 0) return "Не удалось подключиться. Сохранённая сеть не изменена."; + if (strcmp(state, "fallback") == 0) return "Работает точка доступа Battleship-open."; + return strcmp(state, "connected") == 0 ? "Подключено к сохранённой сети." : "Подключаемся к сохранённой сети…"; +} + static void apply_network_action(network_action_t action) { if (action == NETWORK_ACTION_FALLBACK) enable_fallback_ap(); else if (action == NETWORK_ACTION_CONNECT && esp_wifi_connect() != ESP_OK) ESP_LOGW(kLogTag, "saved network connection request failed"); @@ -315,9 +354,45 @@ static network_action_t network_event_action(bool connected) { static void network_timer_callback(void *unused) { (void)unused; + const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U); network_action_t action; - portENTER_CRITICAL(&s_network_lock); action = network_manager_tick(&s_network_manager, (uint32_t)(esp_timer_get_time() / 1000U)); portEXIT_CRITICAL(&s_network_lock); + portENTER_CRITICAL(&s_network_lock); action = network_manager_tick(&s_network_manager, now_ms); portEXIT_CRITICAL(&s_network_lock); apply_network_action(action); + bool validation_timed_out = false; bool success_notice_expired = false; network_profile_t previous = {0}; bool had_previous = false; + portENTER_CRITICAL(&s_configuration_lock); + validation_timed_out = network_configuration_validation_timed_out(&s_configuration, now_ms); + success_notice_expired = network_configuration_success_notice_expired(&s_configuration, now_ms); + if (validation_timed_out) { previous = s_configuration.previous_profile; had_previous = s_configuration.had_previous_profile; } + if (success_notice_expired) s_configuration.state = NETWORK_CONFIGURATION_IDLE; + portEXIT_CRITICAL(&s_configuration_lock); + if (validation_timed_out) { + portENTER_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_configuration_lock); + network_configuration_finish(&s_configuration, &s_network_manager, false, now_ms); + if (had_previous) network_manager_disconnected(&s_network_manager, now_ms); + portEXIT_CRITICAL(&s_configuration_lock); portEXIT_CRITICAL(&s_network_lock); + if (configure_station_profile(had_previous ? &previous : NULL) == ESP_OK && had_previous) esp_wifi_connect(); + } + if (success_notice_expired && fallback_active()) disable_fallback_ap(); +} + +static void validation_connected_work(void *unused) { + (void)unused; + if (!configuration_is_validating()) return; + network_profile_t candidate = {0}; network_profile_t previous = {0}; bool had_previous = false; + portENTER_CRITICAL(&s_network_lock); candidate = s_network_manager.candidate; portEXIT_CRITICAL(&s_network_lock); + const bool persisted = network_credential_store_replace(&candidate); + const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U); + portENTER_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_configuration_lock); + previous = s_configuration.previous_profile; had_previous = s_configuration.had_previous_profile; + network_configuration_finish(&s_configuration, &s_network_manager, persisted, now_ms); + if (persisted) network_manager_connected(&s_network_manager); + else if (had_previous) network_manager_disconnected(&s_network_manager, now_ms); + portEXIT_CRITICAL(&s_configuration_lock); portEXIT_CRITICAL(&s_network_lock); + if (!persisted) { + ESP_LOGW(kLogTag, "network credential storage failed"); + esp_wifi_disconnect(); configure_station_profile(had_previous ? &previous : NULL); + if (had_previous) esp_wifi_connect(); + } } static void wifi_event_handler(void *argument, esp_event_base_t event_base, int32_t event_id, void *event_data) { @@ -325,12 +400,15 @@ static void wifi_event_handler(void *argument, esp_event_base_t event_base, int3 if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) apply_network_action(network_event_action(false)); else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = false; portEXIT_CRITICAL(&s_wifi_lock); - apply_network_action(network_event_action(false)); + if (!configuration_is_validating()) apply_network_action(network_event_action(false)); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_SCAN_DONE) { s_scan_pending = false; s_scan_ready = true; } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { - network_event_action(true); + bool validating = configuration_is_validating(); + if (validating) { + if (s_server != NULL) httpd_queue_work(s_server, validation_connected_work, NULL); + } else network_event_action(true); portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.connected = true; portEXIT_CRITICAL(&s_wifi_lock); - if (fallback_active()) disable_fallback_ap(); + if (!validating && fallback_active()) disable_fallback_ap(); } } @@ -414,26 +492,106 @@ static esp_err_t setup_handler(httpd_req_t *request) { static size_t append_scan_name(char *output, size_t capacity, size_t length, const uint8_t *ssid) { if (length >= capacity) return length; for (size_t index = 0U; index < 32U && ssid[index] != '\0' && length + 1U < capacity; ++index) { - const uint8_t value = ssid[index]; output[length++] = value >= 32U && value <= 126U ? (char)value : '?'; + const uint8_t value = ssid[index]; output[length++] = value >= 32U && value <= 126U && value != '"' && value != '\\' ? (char)value : '?'; } output[length++] = '\n'; output[length] = '\0'; return length; } static esp_err_t setup_scan_handler(httpd_req_t *request) { - if (!fallback_active()) { httpd_resp_set_status(request, "409 Conflict"); return httpd_resp_send(request, "fallback access point is inactive", HTTPD_RESP_USE_STRLEN); } + return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "use /api/network/scan"); +} + +static void network_response(httpd_req_t *request, uint16_t status, const char *body) { + httpd_resp_set_status(request, status == 200U ? "200 OK" : status == 400U ? "400 Bad Request" : status == 409U ? "409 Conflict" : "503 Service Unavailable"); + httpd_resp_set_type(request, "application/json"); httpd_resp_set_hdr(request, "Cache-Control", "no-store"); + httpd_resp_send(request, body, HTTPD_RESP_USE_STRLEN); +} + +static void network_json_skip(const char **text) { while (**text == ' ' || **text == '\n' || **text == '\r' || **text == '\t') ++*text; } +static bool network_json_string(const char **text, char *output, size_t output_size) { + network_json_skip(text); if (**text != '"' || output_size == 0U) return false; ++*text; size_t length = 0U; + while (**text != '\0' && **text != '"') { const unsigned char value = (unsigned char)*(*text)++; if (value < 0x20U || value == '\\' || length + 1U >= output_size) return false; output[length++] = (char)value; } + if (**text != '"') return false; + ++*text; output[length] = '\0'; return true; +} +static bool parse_network_profile(const char *body, network_profile_t *profile) { + if (body == NULL || profile == NULL) return false; + const char *text = body; char key[16] = {0}; bool ssid = false; bool password = false; memset(profile, 0, sizeof(*profile)); + network_json_skip(&text); if (*text++ != '{') return false; + for (;;) { + network_json_skip(&text); if (*text == '}') { ++text; break; } + if ((ssid || password) && *text++ != ',') return false; + if (!network_json_string(&text, key, sizeof(key))) return false; + network_json_skip(&text); if (*text++ != ':') return false; + if (strcmp(key, "ssid") == 0 && !ssid) { if (!network_json_string(&text, profile->ssid, sizeof(profile->ssid))) return false; ssid = true; } + else if (strcmp(key, "password") == 0 && !password) { if (!network_json_string(&text, profile->password, sizeof(profile->password))) return false; password = true; } + else return false; + } + network_json_skip(&text); return *text == '\0' && ssid && password && network_profile_valid(profile); +} + +static esp_err_t network_status_handler(httpd_req_t *request) { + bool saved; + portENTER_CRITICAL(&s_network_lock); saved = s_network_manager.has_profile; portEXIT_CRITICAL(&s_network_lock); + char body[320]; snprintf(body, sizeof(body), "{\"ok\":true,\"state\":\"%s\",\"hasSavedNetwork\":%s,\"message\":\"%s\"}", configuration_state_name(), saved ? "true" : "false", configuration_message()); + network_response(request, 200U, body); return ESP_OK; +} + +static esp_err_t network_scan_handler(httpd_req_t *request) { + if (configuration_is_validating()) { network_response(request, 409U, "{\"ok\":false,\"code\":\"NETWORK_BUSY\",\"message\":\"Идёт проверка подключения.\"}"); return ESP_OK; } if (!s_scan_pending && !s_scan_ready) { - const esp_err_t result = esp_wifi_scan_start(NULL, false); - if (result != ESP_OK) { httpd_resp_set_status(request, "503 Service Unavailable"); return httpd_resp_send(request, "scan unavailable", HTTPD_RESP_USE_STRLEN); } + if (esp_wifi_scan_start(NULL, false) != ESP_OK) { network_response(request, 503U, "{\"ok\":false,\"code\":\"SCAN_UNAVAILABLE\",\"message\":\"Поиск сетей сейчас недоступен.\"}"); return ESP_OK; } s_scan_pending = true; } - httpd_resp_set_type(request, "text/plain; charset=utf-8"); httpd_resp_set_hdr(request, "Cache-Control", "no-store"); - if (s_scan_pending) return httpd_resp_send(request, "scanning\n", HTTPD_RESP_USE_STRLEN); + if (s_scan_pending) { network_response(request, 200U, "{\"ok\":true,\"state\":\"scanning\",\"networks\":[]}"); return ESP_OK; } wifi_ap_record_t records[kScanResultLimit] = {0}; uint16_t count = kScanResultLimit; - if (esp_wifi_scan_get_ap_records(&count, records) != ESP_OK) { httpd_resp_set_status(request, "503 Service Unavailable"); return httpd_resp_send(request, "scan unavailable", HTTPD_RESP_USE_STRLEN); } + if (esp_wifi_scan_get_ap_records(&count, records) != ESP_OK) { network_response(request, 503U, "{\"ok\":false,\"code\":\"SCAN_UNAVAILABLE\",\"message\":\"Поиск сетей сейчас недоступен.\"}"); return ESP_OK; } s_scan_ready = false; - char names[(kNetworkSsidBytes + 1U) * kScanResultLimit + 1U] = {0}; size_t length = 0U; - for (uint16_t index = 0U; index < count; ++index) length = append_scan_name(names, sizeof(names), length, records[index].ssid); - return httpd_resp_send(request, names, length); + char body[kNetworkResponseBytes] = "{\"ok\":true,\"state\":\"ready\",\"networks\":["; size_t length = strlen(body); + for (uint16_t index = 0U; index < count && length + kNetworkSsidBytes + 4U < sizeof(body); ++index) { + char name[kNetworkSsidBytes + 1U] = {0}; append_scan_name(name, sizeof(name), 0U, records[index].ssid); name[strcspn(name, "\n")] = '\0'; + length += (size_t)snprintf(body + length, sizeof(body) - length, "%s\"%s\"", index == 0U ? "" : ",", name); + } + snprintf(body + length, sizeof(body) - length, "]}"); network_response(request, 200U, body); return ESP_OK; +} + +static esp_err_t receive_network_profile(httpd_req_t *request, network_profile_t *profile) { + char body[kNetworkRequestBytes + 1U] = {0}; + if (request->content_len <= 0 || request->content_len > kNetworkRequestBytes || !request_has_json_content_type(request)) { record_rejected_input(); network_response(request, 400U, "{\"ok\":false,\"code\":\"MALFORMED_NETWORK\",\"message\":\"Укажите название сети и пароль.\"}"); return ESP_FAIL; } + size_t length = 0U; + while (length < (size_t)request->content_len) { const int received = httpd_req_recv(request, body + length, request->content_len - length); if (received <= 0) { record_rejected_input(); network_response(request, 400U, "{\"ok\":false,\"code\":\"MALFORMED_NETWORK\",\"message\":\"Некорректный запрос.\"}"); return ESP_FAIL; } length += (size_t)received; } + body[length] = '\0'; + if (!parse_network_profile(body, profile)) { record_rejected_input(); network_response(request, 400U, "{\"ok\":false,\"code\":\"INVALID_NETWORK\",\"message\":\"Проверьте название сети и пароль.\"}"); return ESP_FAIL; } + return ESP_OK; +} + +static esp_err_t network_validate_handler(httpd_req_t *request) { + network_profile_t candidate = {0}; if (receive_network_profile(request, &candidate) != ESP_OK) return ESP_OK; + if (s_scan_pending || configuration_is_validating()) { network_response(request, 409U, "{\"ok\":false,\"code\":\"NETWORK_BUSY\",\"message\":\"Другая операция уже выполняется.\"}"); return ESP_OK; } + const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U); bool started; + portENTER_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_configuration_lock); + started = network_configuration_begin(&s_configuration, &s_network_manager, &candidate, now_ms); + portEXIT_CRITICAL(&s_configuration_lock); portEXIT_CRITICAL(&s_network_lock); + if (!started) { network_response(request, 409U, "{\"ok\":false,\"code\":\"NETWORK_BUSY\",\"message\":\"Другая операция уже выполняется.\"}"); return ESP_OK; } + enable_fallback_ap(); esp_wifi_disconnect(); + if (configure_station_profile(&candidate) != ESP_OK || esp_wifi_connect() != ESP_OK) { + portENTER_CRITICAL(&s_network_lock); portENTER_CRITICAL(&s_configuration_lock); + network_configuration_finish(&s_configuration, &s_network_manager, false, now_ms); + portEXIT_CRITICAL(&s_configuration_lock); portEXIT_CRITICAL(&s_network_lock); + network_response(request, 503U, "{\"ok\":false,\"code\":\"CONNECT_UNAVAILABLE\",\"message\":\"Не удалось начать проверку сети.\"}"); return ESP_OK; + } + network_response(request, 200U, "{\"ok\":true,\"state\":\"validating\",\"message\":\"Проверяем подключение. Оставайтесь в Battleship-open до подтверждения.\"}"); return ESP_OK; +} + +static esp_err_t network_delete_handler(httpd_req_t *request) { + if (request->content_len != 0 || configuration_is_validating() || s_scan_pending) { network_response(request, 409U, "{\"ok\":false,\"code\":\"NETWORK_BUSY\",\"message\":\"Операция сейчас недоступна.\"}"); return ESP_OK; } + if (!network_credential_store_delete()) { network_response(request, 503U, "{\"ok\":false,\"code\":\"STORAGE_UNAVAILABLE\",\"message\":\"Не удалось удалить сохранённую сеть.\"}"); return ESP_OK; } + const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000U); + portENTER_CRITICAL(&s_network_lock); network_manager_init(&s_network_manager, NULL, now_ms); portEXIT_CRITICAL(&s_network_lock); + portENTER_CRITICAL(&s_configuration_lock); network_configuration_init(&s_configuration); portEXIT_CRITICAL(&s_configuration_lock); + portENTER_CRITICAL(&s_wifi_lock); s_wifi_state.configured = false; s_wifi_state.connected = false; portEXIT_CRITICAL(&s_wifi_lock); + esp_wifi_disconnect(); configure_station_profile(NULL); enable_fallback_ap(); + network_response(request, 200U, "{\"ok\":true,\"state\":\"fallback\",\"message\":\"Сохранённая сеть удалена. Battleship-open остаётся доступной.\"}"); return ESP_OK; } static esp_err_t root_handler(httpd_req_t *request) { @@ -458,12 +616,16 @@ static esp_err_t static_file_handler(httpd_req_t *request) { static esp_err_t start_http_server(void) { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); - config.max_uri_handlers = 21U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true; + config.max_uri_handlers = 25U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true; ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), kLogTag, "http server start failed"); const httpd_uri_t routes[] = { {.uri = "/", .method = HTTP_GET, .handler = root_handler}, {.uri = "/setup", .method = HTTP_GET, .handler = setup_handler}, {.uri = "/setup/scan", .method = HTTP_GET, .handler = setup_scan_handler}, + {.uri = "/api/network/status", .method = HTTP_GET, .handler = network_status_handler}, + {.uri = "/api/network/scan", .method = HTTP_GET, .handler = network_scan_handler}, + {.uri = "/api/network/validate", .method = HTTP_POST, .handler = network_validate_handler}, + {.uri = "/api/network/delete", .method = HTTP_POST, .handler = network_delete_handler}, {.uri = "/api/info", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_INFO}, {.uri = "/api/health", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_HEALTH}, {.uri = "/api/session/join", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_JOIN}, @@ -499,6 +661,7 @@ void app_main(void) { const esp_timer_create_args_t bot_timer_args = {.callback = bot_timer_callback, .name = "bot_turn"}; ESP_ERROR_CHECK(esp_timer_create(&bot_timer_args, &s_bot_timer)); application_set_bot_scheduler(&s_application, (scheduler_t){.schedule_after_ms = schedule_bot_turn, .context = NULL}); + network_configuration_init(&s_configuration); http_api_init(&s_api, &s_application); sync_service_init(&s_sync, &s_application); mount_littlefs(); diff --git a/src/network_configuration.c b/src/network_configuration.c new file mode 100644 index 0000000..a19bdc2 --- /dev/null +++ b/src/network_configuration.c @@ -0,0 +1,25 @@ +#include "network_configuration.h" + +#include + +void network_configuration_init(network_configuration_t *configuration) { memset(configuration, 0, sizeof(*configuration)); } +bool network_configuration_begin(network_configuration_t *configuration, network_manager_t *manager, const network_profile_t *candidate, uint32_t now_ms) { + if (configuration == NULL || manager == NULL || configuration->state == NETWORK_CONFIGURATION_VALIDATING || !network_manager_begin_validation(manager, candidate)) return false; + configuration->previous_profile = manager->profile; configuration->had_previous_profile = manager->has_profile; + configuration->state = NETWORK_CONFIGURATION_VALIDATING; configuration->validation_deadline_ms = now_ms + kNetworkValidationWindowMs; + return true; +} +bool network_configuration_validation_timed_out(const network_configuration_t *configuration, uint32_t now_ms) { + return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_VALIDATING && (int32_t)(now_ms - configuration->validation_deadline_ms) >= 0; +} +network_action_t network_configuration_finish(network_configuration_t *configuration, network_manager_t *manager, bool success, uint32_t now_ms) { + if (configuration == NULL || manager == NULL || configuration->state != NETWORK_CONFIGURATION_VALIDATING) return NETWORK_ACTION_NONE; + const network_action_t action = network_manager_finish_validation(manager, success, now_ms); + configuration->state = success ? NETWORK_CONFIGURATION_SUCCESS : NETWORK_CONFIGURATION_FAILED; + if (success) configuration->success_deadline_ms = now_ms + kNetworkSuccessNoticeMs; + return action; +} +bool network_configuration_success_notice_expired(const network_configuration_t *configuration, uint32_t now_ms) { + return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_SUCCESS && (int32_t)(now_ms - configuration->success_deadline_ms) >= 0; +} +bool network_configuration_busy(const network_configuration_t *configuration) { return configuration != NULL && configuration->state == NETWORK_CONFIGURATION_VALIDATING; } diff --git a/test/host/Makefile b/test/host/Makefile index 6a63252..52b3b0f 100644 --- a/test/host/Makefile +++ b/test/host/Makefile @@ -1,7 +1,7 @@ CC ?= cc CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include -all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal +all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal test_network_configuration test_command_queue: test_command_queue.c ../../src/command_queue.c $(CC) $(CFLAGS) $^ -o $@ @@ -19,6 +19,7 @@ run: all ./test_robustness ./test_network_foundation ./test_captive_portal + ./test_network_configuration test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c $(CC) $(CFLAGS) $^ -o $@ @@ -54,5 +55,8 @@ test_network_foundation: test_network_foundation.c ../../src/network_state.c ../ test_captive_portal: test_captive_portal.c ../../src/captive_portal.c $(CC) $(CFLAGS) $^ -o $@ +test_network_configuration: test_network_configuration.c ../../src/network_configuration.c ../../src/network_state.c + $(CC) $(CFLAGS) $^ -o $@ + clean: - rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal + rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_network_foundation test_captive_portal test_network_configuration diff --git a/test/host/test_network_configuration.c b/test/host/test_network_configuration.c new file mode 100644 index 0000000..0a15650 --- /dev/null +++ b/test/host/test_network_configuration.c @@ -0,0 +1,16 @@ +#include +#include +#include +#include "network_configuration.h" + +static network_profile_t profile(const char *ssid, const char *password) { network_profile_t value = {0}; snprintf(value.ssid, sizeof(value.ssid), "%s", ssid); snprintf(value.password, sizeof(value.password), "%s", password); return value; } +int main(void) { + network_manager_t manager; network_configuration_t configuration; network_profile_t old = profile("Old", "old-pass"); network_profile_t next = profile("New", "new-pass"); + network_manager_init(&manager, &old, 0U); network_configuration_init(&configuration); + assert(network_configuration_begin(&configuration, &manager, &next, 10U)); assert(network_configuration_busy(&configuration)); + assert(!network_configuration_begin(&configuration, &manager, &next, 11U)); assert(!network_configuration_validation_timed_out(&configuration, 30009U)); assert(network_configuration_validation_timed_out(&configuration, 30010U)); + assert(network_configuration_finish(&configuration, &manager, false, 30010U) == NETWORK_ACTION_NONE); assert(strcmp(manager.profile.ssid, "Old") == 0); + assert(network_configuration_begin(&configuration, &manager, &next, 40000U)); assert(network_configuration_finish(&configuration, &manager, true, 40001U) == NETWORK_ACTION_CONNECT); assert(strcmp(manager.profile.ssid, "New") == 0); + assert(!network_configuration_success_notice_expired(&configuration, 55000U)); assert(network_configuration_success_notice_expired(&configuration, 55001U)); + puts("network configuration tests passed"); return 0; +}