From d660038e3d996aafa4c42ed3ee9d4adee685f646 Mon Sep 17 00:00:00 2001 From: Shantanu Mane Date: Tue, 4 Aug 2026 21:01:25 +0530 Subject: [PATCH] feat(wifi): add SoftAP captive-portal Wi-Fi provisioning - Add wifi_provision.cpp/.h: open AP edge-dns-setup, captive DNS responder, and /scan + /provision + wildcard portal httpd - Add provision_validate.cpp/.h: pure SSID/password validation against 802.11/WPA2-PSK limits, host-tested - Refactor wifi_connect() into wifi_connect_sta(): read/write Wi-Fi config via esp_wifi's own NVS storage instead of hardcoded credentials, seed from wifi_credentials.h only on first boot - Add 30s connect timeout that falls back to the provisioning portal without erasing stored credentials - Add factory_reset_watch_start(): GPIO0/BOOT hold (~5s) wipes stored Wi-Fi config and reboots into provisioning - Wire the boot branch in main.cpp: provisioned+connected starts the DNS/HTTP/mDNS/OTA tasks as before, otherwise starts the portal - Update ARCHITECTURE.md and README.md for the new boot flow, provisioning walkthrough, and no-auth-by-design portal routes --- ARCHITECTURE.md | 16 +- README.md | 24 +- host_test/main/CMakeLists.txt | 3 +- host_test/main/test_dns_wire.cpp | 25 ++ host_test/main/test_provision_validate.cpp | 78 ++++ main/CMakeLists.txt | 5 +- main/main.cpp | 15 +- main/provision_validate.cpp | 29 ++ main/provision_validate.h | 20 + main/wifi_connect.cpp | 81 +++- main/wifi_connect.h | 12 +- main/wifi_provision.cpp | 493 +++++++++++++++++++++ main/wifi_provision.h | 19 + 13 files changed, 789 insertions(+), 31 deletions(-) create mode 100644 host_test/main/test_provision_validate.cpp create mode 100644 main/provision_validate.cpp create mode 100644 main/provision_validate.h create mode 100644 main/wifi_provision.cpp create mode 100644 main/wifi_provision.h diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3a56def..53900da 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -30,6 +30,8 @@ sequenceDiagram Note over dns_task,httpd: All three keep running independently.
No shared mutable state between them. ``` +**As of Phase 7b, boot is a two-way branch, not the single path the diagram above shows.** `wifi_connect_sta()` returns `false` if it can't get an IP within 30s (no stored/seedable config, or a dead network); `app_main` then calls `wifi_provision_start()` instead of starting the DNS/HTTP/mDNS/OTA tasks and the factory-reset watcher — see `main/wifi_provision.cpp`. The two branches never run concurrently and share no state, so this doesn't change the "no shared mutable state between tasks" property below, it just means the diagram's `dns_task`/`httpd`/`mdns` participants only exist on the success path. + Four independent runtime pieces exist after boot, with **no synchronization between them**: 1. **Wi-Fi event handling** (`wifi_connect.cpp`) — registered event handlers keep running for the life of the device (auto-reconnect on disconnect, plus IPv6 link-local bring-up as of Phase 4), but `wifi_connect()` itself only blocks `app_main` during initial IPv4 bring-up. @@ -47,9 +49,11 @@ Three pieces of state cross the DNS task / HTTP task boundary. Two do it without | File | Responsibility | |---|---| -| `main/main.cpp` | Boot sequence: logs detected PSRAM size → `wifi_connect()` → `dns_server_start()` → `http_server_start()` → `mdns_responder_start()`. | -| `main/wifi_connect.h/.cpp` | Blocking Wi-Fi station bring-up with hardcoded credentials. Retries forever on disconnect (2s backoff), no give-up state. As of Phase 4, also triggers IPv6 link-local address creation on `WIFI_EVENT_STA_CONNECTED` (non-blocking, doesn't gate boot readiness) so mDNS has an address to advertise as AAAA. | -| `main/wifi_credentials.h` | `WIFI_SSID`/`WIFI_PASSWORD` constants. **Gitignored** — does not exist on a fresh checkout, must be recreated. | +| `main/main.cpp` | Boot sequence: logs detected PSRAM size → `wifi_connect_sta()`; on success, `dns_server_start()` → `http_server_start()` → `mdns_responder_start()` → `ota_updater_start()` → `factory_reset_watch_start()`; on failure (no reachable network within 30s), `wifi_provision_start()` instead (Phase 7b). | +| `main/wifi_connect.h/.cpp` | Blocking Wi-Fi station bring-up. As of Phase 7b, `wifi_connect_sta()` reads config from esp_wifi's own NVS blob rather than a hardcoded pair, seeding from `wifi_credentials.h` only on first boot, and returns `false` (rather than blocking forever) if no IP arrives within 30s. Retries forever on disconnect *after* a first successful connect (2s backoff, unchanged from earlier phases). As of Phase 4, also triggers IPv6 link-local address creation on `WIFI_EVENT_STA_CONNECTED` (non-blocking, doesn't gate boot readiness) so mDNS has an address to advertise as AAAA. | +| `main/wifi_credentials.h` | `WIFI_SSID`/`WIFI_PASSWORD` constants. **Gitignored** — does not exist on a fresh checkout, must be recreated. As of Phase 7b, only a first-boot seed for esp_wifi's own NVS storage, not the ongoing source of truth. | +| `main/wifi_provision.h/.cpp` | SoftAP captive-portal provisioning (Phase 7b): brings up `edge-dns-setup` (open AP, `WIFI_MODE_APSTA`), a minimal captive DNS responder, and an `httpd` instance serving `/scan` (JSON list of nearby networks), `/provision` (validates and writes STA config, then reboots), and a wildcard portal page. Also owns `factory_reset_watch_start()` (GPIO0/BOOT hold → `esp_wifi_restore()` + reboot). No auth on any of its routes — the AP being open is the access control. | +| `main/provision_validate.h/.cpp` | Pure SSID/password validation against 802.11/WPA2-PSK byte-length limits (Phase 7b) — no IDF/FreeRTOS dependency, host-tested like `dns_wire.cpp`/`ota_version.cpp`. Used by `wifi_provision.cpp`'s `/provision` handler before touching `esp_wifi_set_config()`. | | `main/admin_credentials.h` | `ADMIN_USER`/`ADMIN_PASS` constants for Basic auth on the mutating `/api/records` routes (Phase 5). **Gitignored**, same pattern as `wifi_credentials.h` — does not exist on a fresh checkout, must be recreated. | | `main/dns_records.h` | The seed hostname→IPv4 table (`DNS_RECORDS_DEFAULTS`, a `constexpr std::array`), consulted only once by `DnsRecordStore::load_from_nvs()` on first boot (Phase 5) — the live table lives in NVS afterward. Pure data, no logic, no dependencies beyond ``/``. IPv4-only by design — AAAA for a seeded name is added later through the CRUD API (Phase 6). | | `main/dns_record_store.h/.cpp` | Runtime-managed DNS record table (`DnsRecordStore`, Phase 5) — NVS-persisted, guarded by a `std::mutex` since it's the first cross-task state that's genuinely shared *and* mutable. Each entry holds an optional IPv4 *and* optional IPv6 address (Phase 6 dual-stack — never neither). DNS task calls `find()` (returns a copy of the whole entry, not a pointer); HTTP task calls `snapshot()`/`create()`/`update()`/`remove()`. | @@ -133,13 +137,15 @@ These are the things most likely to confuse future-you or bite an extension: - **`httpd_config_t::max_uri_handlers` is a small, easy-to-undercount budget** (Phase 5) — the same shape as the `CONFIG_LWIP_MAX_SOCKETS` trap below. The default (8) was an exact fit for the 4 original GET-only routes; Phase 5's 4 new `/api/records` methods (`POST`/`PUT`/`DELETE`/`OPTIONS`) made it an exact fit again, so it was bumped to 12 for headroom. The next endpoint added anywhere needs this re-checked, not just incremented by one. - **Metrics counters are `uint32_t`, not `uint64_t`, on purpose** (Phase 3) — the Xtensa LX7 has no native 64-bit atomic instructions, so `std::atomic` here would silently fall back to libatomic's lock-based implementation, reintroducing a lock into an otherwise deliberately lock-free cross-task read path. A 32-bit counter wrapping after ~4B events is fine for a LAN-scale device scraped by Prometheus, which already tolerates counter resets via `rate()`. - **Automated tests exist for the wire-format layer only (Phase 6), everything else is still manual/hardware-in-the-loop.** `host_test/` builds `main/dns_wire.cpp` unmodified against ESP-IDF's `linux` target and Unity — `idf.py --preview set-target linux -C host_test build && ./host_test/build/host_test.elf` (no board, no QEMU, since that file has no FreeRTOS/lwIP dependency). The DNS/HTTP task logic, NVS persistence, and everything with a socket in it are still only verified by flashing and `dig`/`curl`/browser/serial-log inspection — that's a real gap if this code keeps growing, but extending it needs fakes/mocks for sockets and NVS that `dns_wire.cpp`'s pure functions don't. +- **The provisioning portal (Phase 7b) is deliberately unauthenticated** — `/scan`, `/provision`, and the wildcard portal page in `wifi_provision.cpp` carry no auth check, unlike every route in `http_server.cpp`. This is a considered choice, not an oversight: the AP is open and unprovisioned by definition, there is no credential yet in existence to gate it behind, and gating it behind `admin_credentials.h` would make a factory-fresh device unprovisionable. Provisioning mode and normal operation never run concurrently (see the boot-branch note above), so this doesn't weaken the auth story for `/api/records` et al. — physical/RF proximity to join `edge-dns-setup` is the actual access control here, same reasoning as an unlocked configuration port on other consumer hardware. +- **STA reconfiguration on an already-started Wi-Fi stack needs an explicit stop first.** `wifi_provision_start()` is only reached after `wifi_connect_sta()` already called `esp_wifi_set_mode(WIFI_MODE_STA)`/`esp_wifi_start()` and its disconnect handler is actively retrying in the background. Calling `esp_wifi_set_mode(WIFI_MODE_APSTA)`/`esp_wifi_start()` again without stopping first isn't a supported transition, so `wifi_provision_start()` calls `esp_wifi_stop()` up front (tolerating `ESP_ERR_WIFI_NOT_STARTED`) before reconfiguring — easy to miss if this bring-up sequence is ever refactored. - **The `linux` target's FreeRTOS port never returns from `vTaskStartScheduler()`, so `app_main()` returning doesn't end the process** — `host_test/main/test_dns_wire.cpp`'s `app_main()` calls `exit(UNITY_END())` itself rather than just returning, both to actually terminate and to turn Unity's failure count into a real process exit code for CI. Worth remembering before adding a second host-test binary: without an explicit `exit()`, running it from a script will hang forever after printing results, not just exit slowly. ## Explicit non-goals (as scoped) These were ruled out deliberately, not overlooked — don't reintroduce them without reopening the scoping conversation: -- Wi-Fi provisioning UI / captive portal +- ~~Wi-Fi provisioning UI / captive portal~~ — **promoted, done (Phase 7b)**: see Future scoping item 8 above. - TLS/HTTPS — Basic auth (Phase 5) runs over plaintext HTTP; see the gotcha above - NVS-stored/rotatable admin credentials, rate limiting on auth failures — see the Phase 5 design doc's Open threads - True iterative/recursive DNS resolution (root-server walking) — forwarding + caching was built instead (Phase 1); see the design doc for the rationale @@ -159,7 +165,7 @@ Roughly ordered by how naturally each extends the current design, not by priorit 5. ~~**Record management: NVS-backed persistence + a real add/edit/delete API + basic auth.**~~ **Done (Phase 5).** Scoped as one phase, not three, because the pieces are load-bearing for each other: `POST`/`PUT`/`DELETE` on `/api/records` is worthless without persistence behind it, and shipping either without auth would leave a mutating, unauthenticated endpoint exposed on the LAN — so none of the three shipped without the other two. Also the single biggest architectural jump so far: `DnsRecordStore` is the first cross-task state that's genuinely shared *and* mutable, guarded by a `std::mutex` — every prior phase (blocklist, metrics) avoided this by keeping cross-task state either immutable-after-boot or a single atomic word. Ships a JSON CRUD API only, no bundled frontend — the record-management UI is a separate project. See the concurrency-model section above and `docs/superpowers/specs/2026-07-21-edge-dns-phase5-record-management-design.md`. 6. ~~**Hardening & reliability (Phase 6): AAAA record support for local-table names + host-side unit tests for the wire-format functions + a secondary/failover upstream resolver.**~~ **Done (Phase 6).** Bundled because none of the three touch the concurrency model or add new attack surface — unlike Phase 5, this phase rounds out correctness and resilience of what Phases 1–4 already shipped, rather than adding a new capability surface. `DnsRecordEntry` now holds an optional IPv4 *and* optional IPv6 address (never neither); `find()` returns the whole entry so the DNS task can tell "name exists, wrong family" (NODATA) apart from "name doesn't exist" (NXDOMAIN) — forwarded/cached AAAA for *non*-local names already worked as of Phase 1 (the forwarder and cache are qtype-agnostic). The pure functions in `dns_wire.cpp` are covered by a Unity suite building against ESP-IDF's `linux` target (`host_test/`, no board/QEMU needed). A slot whose primary-upstream attempt times out is retried once against a secondary before SERVFAIL, reusing the same transaction ID/socket. See `docs/superpowers/specs/2026-07-21-edge-dns-phase6-hardening-design.md`. 7. ~~**Leaving the bench, part 1 (Phase 7a): dual OTA partitions + signed, self-updating firmware.**~~ **Done (Phase 7a).** `otadata`/`ota_0`/`ota_1` replace the single `factory` partition (see partitions.csv); a background task in `ota_updater.cpp` polls GitHub Releases every 6 hours (or on demand via `POST /api/ota/check`), downloads via `esp_https_ota()`, and only cancels the bootloader's rollback after a real health check (30s uptime + at least one DNS query answered, or 10 minutes uptime regardless of traffic) — not unconditionally at boot. Until that gate passes, `esp_ota_begin()` itself refuses new OTA attempts, so the update endpoint rejects with 409 rather than failing mid-download. Signed with `CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT` (software-checked signature) rather than full Secure Boot v2, which would burn eFuses irreversibly. `GET /api/ota` / `POST /api/ota/check` expose status and a manual trigger. -8. **Leaving the bench, part 2 (Phase 7b): Wi-Fi provisioning via SoftAP captive portal.** Still an explicit non-goal today (see above). Split from Phase 7a because the two share no dependency beyond the one-time partition-table rewrite Phase 7a already did. +8. ~~**Leaving the bench, part 2 (Phase 7b): Wi-Fi provisioning via SoftAP captive portal.**~~ **Done (Phase 7b).** `wifi_connect_sta()` reads Wi-Fi config from esp_wifi's own NVS blob (`nvs.net80211`) rather than a hand-rolled store — `esp_wifi_get_config()` empty is the "unprovisioned" check, seeding from `wifi_credentials.h` on first boot; the boot-time connect attempt now has a 30s timeout instead of blocking forever. On timeout, `wifi_provision_start()` (`main/wifi_provision.cpp`) brings the device up as its own open AP (`edge-dns-setup`, `WIFI_MODE_APSTA` so scanning still works), reconfigures the AP's DHCP server to hand out itself as DNS, and runs a minimal captive DNS responder (every A query answered with the AP's own IP, everything else NODATA) alongside a small `httpd` instance serving `/scan`, `/provision`, and a wildcard portal page — deliberately a separate, much smaller responder rather than a mode flag threaded through `dns_server.cpp`'s hot query path, since the two never run concurrently and AP mode has no upstream/cache/record-table state to share with it. `POST /provision` validates via the pure `provision_validate()` (`main/provision_validate.h/.cpp`, host-tested) before writing `esp_wifi_set_config()` and rebooting into STA mode; a failed STA connect falls back to the portal *without* erasing stored credentials, so a merely-rebooting router recovers on its own next cycle. Holding GPIO0 (BOOT) ~5s (`factory_reset_watch_start()`, STA mode only) calls `esp_wifi_restore()` and reboots — the recovery path for a wrong stored password with no cable handy. The provisioning routes carry no auth: the AP is open by definition, and physical proximity to join it is the only access control available at that point. 9. **Reliability & forensics (Phase 7c): coredump-to-flash, task watchdog, heap/uptime/reset-reason metrics.** Also split from 7a/7b for the same reason — independent of both beyond the partition table. Lower-value, opportunistic — worth doing if a specific need arises, not currently phase-scoped: diff --git a/README.md b/README.md index 6573c2a..5691c40 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A minimal ESP32-S3 firmware that connects to Wi-Fi, resolves a runtime-managed set of hostnames over DNS (UDP/53), forwards everything else to an upstream resolver with TTL caching, sinkholes ad/tracker domains, advertises itself via mDNS, and serves an HTTP page + a JSON CRUD API + Prometheus metrics. -Proof-of-concept moving toward a marketable "edge DNS" appliance — no provisioning UI, no OTA, no TLS. Records are NVS-persisted and editable via a Basic-auth-gated CRUD API as of Phase 5, dual-stack (A+AAAA) as of Phase 6, with a secondary-upstream retry on forward timeout and a host-side Unity test suite for the wire-format layer (see below). See [`ARCHITECTURE.md`](ARCHITECTURE.md) for design details, gotchas, and future scoping. +Proof-of-concept moving toward a marketable "edge DNS" appliance — no TLS. Records are NVS-persisted and editable via a Basic-auth-gated CRUD API as of Phase 5, dual-stack (A+AAAA) as of Phase 6, self-updating over signed OTA as of Phase 7a, and self-provisioning over a SoftAP captive portal as of Phase 7b (see below) — with a secondary-upstream retry on forward timeout and a host-side Unity test suite for the wire-format and validation layers. See [`ARCHITECTURE.md`](ARCHITECTURE.md) for design details, gotchas, and future scoping. ## Prerequisites @@ -18,7 +18,7 @@ Proof-of-concept moving toward a marketable "edge DNS" appliance — no provisio cd mini_dns ``` -2. **Create `main/wifi_credentials.h`** — this file is gitignored (it holds real Wi-Fi credentials) and does not exist on a fresh clone. Create it with your own values: +2. **Create `main/wifi_credentials.h`** — gitignored, does not exist on a fresh clone, and still required for the build to compile (`wifi_connect.cpp` includes it unconditionally). As of Phase 7b, though, the values in it only matter as a **first-boot seed**: the device persists its actual Wi-Fi config in esp_wifi's own NVS storage, so after the first successful connect this header is never consulted again. If you'd rather not put real credentials in a file at all, placeholder values are fine — the seed just won't connect, the device falls back to its SoftAP portal after 30s, and you provision it from there instead. See [Wi-Fi provisioning](#wi-fi-provisioning-phase-7b) below. ```cpp #pragma once constexpr const char* WIFI_SSID = "your-ssid"; @@ -54,7 +54,19 @@ Finding ``: - macOS: `ls /dev/tty.usbserial-* /dev/tty.usbmodem*` - Linux: usually `/dev/ttyUSB0` or `/dev/ttyACM0` -On boot you should see log lines for Wi-Fi connecting (with the assigned IP), the DNS server binding to port 53, and the HTTP server starting on port 80. Exit the serial monitor with `Ctrl+]`. +On boot you should see log lines for Wi-Fi connecting (with the assigned IP), the DNS server binding to port 53, and the HTTP server starting on port 80. If no network is reachable within 30s, the device instead logs that it's starting the provisioning portal — see below. Exit the serial monitor with `Ctrl+]`. + +## Wi-Fi provisioning (Phase 7b) + +If the device has no working Wi-Fi config — first boot with a bad/placeholder seed, a router that's down, or after a factory reset — it comes up as its own open access point instead of retrying forever: + +1. Join **`edge-dns-setup`** from a phone or laptop (no password). Most OSes pop the captive-portal page automatically; if not, browse to any address — the device answers every DNS query with its own IP (`192.168.4.1` by default) and every HTTP path with the setup form. +2. Pick a network from the scanned list (or type an SSID manually) and enter its password, then submit. +3. The device saves the config, reboots, and joins that network in station mode as usual. + +**Factory reset:** hold the board's **BOOT** button (GPIO0) for about 5 seconds. This wipes the stored Wi-Fi config and reboots straight back into the provisioning portal — the recovery path if you provisioned the wrong password and don't have a cable handy. + +The provisioning portal (`/scan`, `/provision`, and the form itself) is intentionally unauthenticated: the AP is open by definition, and there's no credential yet to gate it behind — anyone close enough to join `edge-dns-setup` already has the same access a cable would give them. ## Testing @@ -117,7 +129,7 @@ idf.py --preview set-target linux -C host_test build ./host_test/build/host_test.elf ``` -Exits 0 with `24 Tests 0 Failures` on success — a nonzero exit is Unity's failure +Exits 0 with `47 Tests 0 Failures` on success — a nonzero exit is Unity's failure count, so this is CI-friendly as-is. ## Continuous Integration @@ -146,4 +158,6 @@ exist as a CI-verified reference build, not a flash-and-go artifact. - **Basic auth runs over plaintext HTTP.** There's no TLS on this device, so credentials for the mutating `/api/records` routes are base64-encoded, not encrypted. Fine on a trusted LAN, not a real security boundary. - **CORS is effectively open.** The mutating routes reflect back whatever `Origin` a request sends (browsers disallow a wildcard alongside credentialed requests) — protection comes entirely from the Basic-auth check, not from origin filtering. - **OTA updates require a signing key you generate once.** `secure_boot_signing_key.pem` is gitignored like `wifi_credentials.h`; generate it with `espsecure.py generate_signing_key --version 2 --scheme ecdsa256 secure_boot_signing_key.pem` before your first build after Phase 7a — the `--scheme ecdsa256` flag matters, since `espsecure.py` defaults to an RSA key otherwise, which this project's ECDSA-based sdkconfig can't sign with. CI has its own copy in a repository secret (`OTA_SIGNING_KEY_PEM`) — see `.github/workflows/ci.yml`. -- **Repartitioning (Phase 7a) requires `idf.py erase-flash`.** This wipes the NVS record store and blocklist — reflash and re-seed from `dns_records.h`/`dns_blocklist_defaults.h`, or re-add records via the CRUD API, after upgrading from a pre-Phase-7a build. +- **Repartitioning (Phase 7a) requires `idf.py erase-flash`.** This wipes the NVS record store, blocklist, and (as of Phase 7b) the stored Wi-Fi config — reflash and re-seed from `dns_records.h`/`dns_blocklist_defaults.h`/`wifi_credentials.h`, or re-provision over the SoftAP portal, after upgrading from a pre-Phase-7a build. +- **The provisioning portal has no auth, by design.** `/scan`, `/provision`, and the setup page are reachable by anyone who joins `edge-dns-setup` — there's no credential yet to gate them behind at that point, and the AP itself being open is the actual access control. This is fine because provisioning mode never runs at the same time as normal operation (the mutating `/api/records` routes stay Basic-auth-gated as always) — see the SoftAP section above. +- **STA connect failures fall back to the portal without erasing stored credentials.** A router that's merely rebooting recovers on its own next power cycle; only a factory reset (BOOT held ~5s) or a fresh `esp_wifi_set_config` via the portal actually changes what's stored. diff --git a/host_test/main/CMakeLists.txt b/host_test/main/CMakeLists.txt index e12f852..53477e3 100644 --- a/host_test/main/CMakeLists.txt +++ b/host_test/main/CMakeLists.txt @@ -1,7 +1,8 @@ # dns_wire.cpp is pulled in directly from the firmware's main/ component # (not linked as a library) so this test always exercises the exact same # source that ships — see the header comment on ../CMakeLists.txt. -idf_component_register(SRCS "test_dns_wire.cpp" "test_ota_version.cpp" +idf_component_register(SRCS "test_dns_wire.cpp" "test_ota_version.cpp" "test_provision_validate.cpp" "../../main/dns_wire.cpp" "../../main/ota_version.cpp" + "../../main/provision_validate.cpp" INCLUDE_DIRS "." "../../main" REQUIRES unity) diff --git a/host_test/main/test_dns_wire.cpp b/host_test/main/test_dns_wire.cpp index e8a6e23..76f85e0 100644 --- a/host_test/main/test_dns_wire.cpp +++ b/host_test/main/test_dns_wire.cpp @@ -31,6 +31,19 @@ void test_is_newer_major_beats_minor_and_patch(); void test_is_newer_false_on_unparseable_remote(); void test_is_newer_false_on_unparseable_current(); +// test_provision_validate.cpp's tests (Phase 7b) — same plain-function, +// external-linkage pattern as the ota_version tests above. +void test_ssid_empty_rejected(); +void test_ssid_length_1_ok(); +void test_ssid_length_32_ok(); +void test_ssid_length_33_too_long(); +void test_password_empty_is_open_network_ok(); +void test_password_length_7_too_short(); +void test_password_length_8_ok(); +void test_password_length_63_ok(); +void test_password_length_64_too_long(); +void test_ssid_multibyte_utf8_within_byte_limit_ok(); + namespace { // Encodes a dotted name ("test.loc") as length-prefixed labels terminated @@ -414,6 +427,18 @@ extern "C" void app_main(void) RUN_TEST(test_is_newer_major_beats_minor_and_patch); RUN_TEST(test_is_newer_false_on_unparseable_remote); RUN_TEST(test_is_newer_false_on_unparseable_current); + // provision_validate tests (Phase 7b) — same plain-RUN_TEST pattern as + // the ota_version tests above, rather than TEST_CASE auto-registration. + RUN_TEST(test_ssid_empty_rejected); + RUN_TEST(test_ssid_length_1_ok); + RUN_TEST(test_ssid_length_32_ok); + RUN_TEST(test_ssid_length_33_too_long); + RUN_TEST(test_password_empty_is_open_network_ok); + RUN_TEST(test_password_length_7_too_short); + RUN_TEST(test_password_length_8_ok); + RUN_TEST(test_password_length_63_ok); + RUN_TEST(test_password_length_64_too_long); + RUN_TEST(test_ssid_multibyte_utf8_within_byte_limit_ok); // ESP-IDF's linux-target port starts the FreeRTOS scheduler before // calling app_main and never tears it down when app_main returns — // the process would otherwise hang forever after printing results. diff --git a/host_test/main/test_provision_validate.cpp b/host_test/main/test_provision_validate.cpp new file mode 100644 index 0000000..1d0d85d --- /dev/null +++ b/host_test/main/test_provision_validate.cpp @@ -0,0 +1,78 @@ +// Host-side unit tests for main/provision_validate.h/.cpp (Phase 7b) — same +// no-FreeRTOS-dependency, linux-target pattern as test_dns_wire.cpp: +// +// idf.py --preview set-target linux -C host_test build +// ./host_test/build/host_test.elf + +#include "provision_validate.h" +#include "unity.h" + +// Not in an anonymous namespace, unlike test_dns_wire.cpp's helpers: these +// need external linkage so test_dns_wire.cpp's app_main (the single merged +// Unity entry point — see its file comment) can RUN_TEST() them via the +// forward declarations there. + +void test_ssid_empty_rejected() +{ + TEST_ASSERT_EQUAL(ProvisionValidation::kSsidEmpty, provision_validate("", "somepassword")); +} + +void test_ssid_length_1_ok() +{ + TEST_ASSERT_EQUAL(ProvisionValidation::kOk, provision_validate("a", "somepassword")); +} + +void test_ssid_length_32_ok() +{ + std::string ssid(32, 'a'); + TEST_ASSERT_EQUAL(ProvisionValidation::kOk, provision_validate(ssid, "somepassword")); +} + +void test_ssid_length_33_too_long() +{ + std::string ssid(33, 'a'); + TEST_ASSERT_EQUAL(ProvisionValidation::kSsidTooLong, provision_validate(ssid, "somepassword")); +} + +void test_password_empty_is_open_network_ok() +{ + TEST_ASSERT_EQUAL(ProvisionValidation::kOk, provision_validate("myssid", "")); +} + +void test_password_length_7_too_short() +{ + std::string password(7, 'p'); + TEST_ASSERT_EQUAL(ProvisionValidation::kPasswordTooShort, provision_validate("myssid", password)); +} + +void test_password_length_8_ok() +{ + std::string password(8, 'p'); + TEST_ASSERT_EQUAL(ProvisionValidation::kOk, provision_validate("myssid", password)); +} + +void test_password_length_63_ok() +{ + std::string password(63, 'p'); + TEST_ASSERT_EQUAL(ProvisionValidation::kOk, provision_validate("myssid", password)); +} + +void test_password_length_64_too_long() +{ + std::string password(64, 'p'); + TEST_ASSERT_EQUAL(ProvisionValidation::kPasswordTooLong, provision_validate("myssid", password)); +} + +void test_ssid_multibyte_utf8_within_byte_limit_ok() +{ + // 16 codepoints of the 3-byte UTF-8 character U+00E9 encoded as 0xC3 0xA9 + // is wrong (that's 2 bytes) — use a 3-byte CJK codepoint instead so 10 + // codepoints == 30 bytes, safely under 32, confirming size() (bytes) is + // used rather than any codepoint-aware length. + std::string ssid; + for (int i = 0; i < 10; ++i) { + ssid += "\xE4\xBD\xA0"; // U+4F60 "you", 3 bytes in UTF-8 + } + TEST_ASSERT_EQUAL_size_t(30, ssid.size()); + TEST_ASSERT_EQUAL(ProvisionValidation::kOk, provision_validate(ssid, "somepassword")); +} diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index b5356fe..f9720c9 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,11 +1,12 @@ # admin_credentials.h is gitignored (like wifi_credentials.h) and must be # created by hand before building — see README.md. Required symbols: # ADMIN_USER, ADMIN_PASS (both const char*). -idf_component_register(SRCS "main.cpp" "wifi_connect.cpp" "dns_server.cpp" "dns_wire.cpp" +idf_component_register(SRCS "main.cpp" "wifi_connect.cpp" "wifi_provision.cpp" + "provision_validate.cpp" "dns_server.cpp" "dns_wire.cpp" "dns_cache.cpp" "dns_forwarder.cpp" "dns_blocklist.cpp" "dns_metrics.cpp" "dns_record_store.cpp" "http_server.cpp" "mdns_responder.cpp" "ota_version.cpp" "ota_updater.cpp" INCLUDE_DIRS "." REQUIRES esp_wifi esp_netif esp_event nvs_flash lwip esp_http_server json esp_psram esp_timer mdns mbedtls app_update esp_https_ota - esp_http_client) + esp_http_client driver) diff --git a/main/main.cpp b/main/main.cpp index dbd1e4b..8902266 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -5,6 +5,7 @@ #include "mdns_responder.h" #include "ota_updater.h" #include "wifi_connect.h" +#include "wifi_provision.h" namespace { constexpr const char *TAG = "main"; @@ -26,9 +27,13 @@ void log_psram_status() extern "C" void app_main(void) { log_psram_status(); - wifi_connect(); - dns_server_start(); - http_server_start(); - mdns_responder_start(); - ota_updater_start(); + if (wifi_connect_sta()) { + dns_server_start(); + http_server_start(); + mdns_responder_start(); + ota_updater_start(); + factory_reset_watch_start(); + } else { + wifi_provision_start(); + } } diff --git a/main/provision_validate.cpp b/main/provision_validate.cpp new file mode 100644 index 0000000..ad64d3c --- /dev/null +++ b/main/provision_validate.cpp @@ -0,0 +1,29 @@ +#include "provision_validate.h" + +ProvisionValidation provision_validate(const std::string &ssid, const std::string &password) +{ + // SSID is a max-32-byte field in the 802.11 beacon/probe frames — not + // a UI choice. Zero-length SSIDs are reserved for the "broadcast" / + // wildcard case and aren't valid for a network to advertise itself as. + if (ssid.empty()) { + return ProvisionValidation::kSsidEmpty; + } + if (ssid.size() > 32) { + return ProvisionValidation::kSsidTooLong; + } + + // Password: 0 bytes is the open-network case, valid on its own. + // Anything else must fall in WPA2-PSK's ASCII passphrase range of + // 8-63 bytes (RSN spec) — 1-7 bytes is neither open nor a legal PSK. + if (password.empty()) { + return ProvisionValidation::kOk; + } + if (password.size() < 8) { + return ProvisionValidation::kPasswordTooShort; + } + if (password.size() > 63) { + return ProvisionValidation::kPasswordTooLong; + } + + return ProvisionValidation::kOk; +} diff --git a/main/provision_validate.h b/main/provision_validate.h new file mode 100644 index 0000000..4917a48 --- /dev/null +++ b/main/provision_validate.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +// Result of validating a SoftAP-provisioned SSID/password pair against +// hard 802.11 / WPA2-PSK limits (not arbitrary policy — see +// provision_validate.cpp for the byte-length rationale on each boundary). +enum class ProvisionValidation { + kOk, + kSsidEmpty, + kSsidTooLong, + kPasswordTooShort, + kPasswordTooLong, +}; + +// Pure function — no IDF/FreeRTOS headers — so this links into host_test/ +// (linux target) without pulling in ESP-IDF wifi headers. Lengths are +// byte lengths (std::string::size()), not codepoints: the 802.11 spec's +// SSID/password limits are byte limits. +ProvisionValidation provision_validate(const std::string &ssid, const std::string &password); diff --git a/main/wifi_connect.cpp b/main/wifi_connect.cpp index dce37eb..67bb3cb 100644 --- a/main/wifi_connect.cpp +++ b/main/wifi_connect.cpp @@ -16,15 +16,32 @@ namespace { constexpr const char *TAG = "wifi_connect"; constexpr int WIFI_CONNECTED_BIT = BIT0; +// How long wifi_connect_sta() waits for a first IP before giving up and +// falling back to the provisioning portal (see main.cpp's boot branch) — a +// router that's merely rebooting recovers on the *next* power cycle instead +// of this one, rather than the device hanging forever on a bad network. +constexpr uint32_t STA_CONNECT_TIMEOUT_MS = 30000; + EventGroupHandle_t s_wifi_event_group; esp_netif_t *s_sta_netif; +// Set once the very first IP is obtained. Doesn't gate the disconnect-retry +// handler below (that stays unconditional, exactly as before this phase) — +// it only distinguishes "still trying the initial connect" from "recovering +// a connection that already worked once" in the log line, so a timeout +// during first boot doesn't read like an ordinary transient drop. +bool s_got_ip_once = false; + void event_handler(void *, esp_event_base_t event_base, int32_t event_id, void *event_data) { if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { esp_wifi_connect(); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { - ESP_LOGW(TAG, "disconnected, retrying..."); + if (s_got_ip_once) { + ESP_LOGW(TAG, "disconnected, retrying..."); + } else { + ESP_LOGW(TAG, "initial connect attempt failed, retrying..."); + } vTaskDelay(pdMS_TO_TICKS(2000)); esp_wifi_connect(); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_CONNECTED) { @@ -36,6 +53,7 @@ void event_handler(void *, esp_event_base_t event_base, int32_t event_id, void * } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { auto *event = static_cast(event_data); ESP_LOGI(TAG, "connected, IP: " IPSTR, IP2STR(&event->ip_info.ip)); + s_got_ip_once = true; xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); } else if (event_base == IP_EVENT && event_id == IP_EVENT_GOT_IP6) { auto *event = static_cast(event_data); @@ -53,9 +71,13 @@ void init_nvs() ESP_ERROR_CHECK(err); } -} // namespace - -void wifi_connect() +// netif/event-loop/esp_wifi_init bring-up shared by both the STA path +// (wifi_connect_sta(), below) and the AP provisioning path +// (wifi_provision_start(), in wifi_provision.cpp). Runs exactly once: it's +// only ever invoked from wifi_connect_sta(), which main.cpp calls exactly +// once at boot, before branching into either mode — see the boot flow in +// main.cpp. +void wifi_stack_init() { init_nvs(); @@ -74,17 +96,56 @@ void wifi_connect() IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler, nullptr, nullptr)); ESP_ERROR_CHECK(esp_event_handler_instance_register( IP_EVENT, IP_EVENT_GOT_IP6, &event_handler, nullptr, nullptr)); +} + +} // namespace +bool wifi_connect_sta() +{ + wifi_stack_init(); + + // esp_wifi already persists wifi_config_t to its own NVS blob + // (nvs.net80211, WIFI_STORAGE_FLASH default) — this get_config() call + // *is* the "am I provisioned?" check, no separate namespace needed. wifi_config_t wifi_config = {}; - std::strncpy(reinterpret_cast(wifi_config.sta.ssid), WIFI_SSID, - sizeof(wifi_config.sta.ssid) - 1); - std::strncpy(reinterpret_cast(wifi_config.sta.password), WIFI_PASSWORD, - sizeof(wifi_config.sta.password) - 1); - wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + ESP_ERROR_CHECK(esp_wifi_get_config(WIFI_IF_STA, &wifi_config)); + + if (wifi_config.sta.ssid[0] == '\0') { + // First boot, nothing stored yet: seed from the gitignored + // wifi_credentials.h convenience header — the same seed-then-persist + // idiom as DnsRecordStore::load_from_nvs()'s first-boot branch, except + // esp_wifi_set_config() below both seeds AND persists in one call, so + // there's no separate save step to write. + ESP_LOGI(TAG, "no stored Wi-Fi config, seeding from wifi_credentials.h"); + std::strncpy(reinterpret_cast(wifi_config.sta.ssid), WIFI_SSID, + sizeof(wifi_config.sta.ssid) - 1); + std::strncpy(reinterpret_cast(wifi_config.sta.password), WIFI_PASSWORD, + sizeof(wifi_config.sta.password) - 1); + wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + } + + if (wifi_config.sta.ssid[0] == '\0') { + // Defensive only: wifi_credentials.h is a required gitignored header, + // so an empty WIFI_SSID shouldn't happen — but starting STA on a blank + // SSID would just burn the 30s wait below for a connection that was + // never going to succeed. + ESP_LOGE(TAG, "no stored config and no seed available, cannot start STA"); + return false; + } ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); ESP_ERROR_CHECK(esp_wifi_start()); - xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT, pdFALSE, pdFALSE, portMAX_DELAY); + // Soft timeout, not portMAX_DELAY: a wrong/dead password must not hang + // the device forever with no way in — see wifi_provision_start() for the + // recovery path this falls back to. + EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT, pdFALSE, pdFALSE, + pdMS_TO_TICKS(STA_CONNECT_TIMEOUT_MS)); + if (!(bits & WIFI_CONNECTED_BIT)) { + ESP_LOGW(TAG, "no IP within %u ms, falling back to provisioning", + static_cast(STA_CONNECT_TIMEOUT_MS)); + return false; + } + return true; } diff --git a/main/wifi_connect.h b/main/wifi_connect.h index 1e04aa7..e25ac58 100644 --- a/main/wifi_connect.h +++ b/main/wifi_connect.h @@ -1,5 +1,11 @@ #pragma once -// Brings up Wi-Fi station mode with hardcoded credentials, blocks until -// an IP is obtained via DHCP, retries indefinitely on failure. -void wifi_connect(); +// Brings up Wi-Fi station mode using stored credentials (esp_wifi's own NVS +// blob — see wifi_connect.cpp), seeding from wifi_credentials.h on first +// boot if nothing is stored yet. Blocks up to 30s waiting for an initial +// IP. Returns false if there is nothing to try (no stored config and no +// seed) or the 30s timeout elapses without connecting; true once connected. +// After a first successful connect, later disconnects retry indefinitely +// in the background regardless of this return value — see the +// WIFI_EVENT_STA_DISCONNECTED handler in wifi_connect.cpp. +bool wifi_connect_sta(); diff --git a/main/wifi_provision.cpp b/main/wifi_provision.cpp new file mode 100644 index 0000000..25ff2b1 --- /dev/null +++ b/main/wifi_provision.cpp @@ -0,0 +1,493 @@ +#include "wifi_provision.h" + +#include +#include +#include +#include +#include +#include + +#include "cJSON.h" +#include "dns_wire.h" +#include "driver/gpio.h" +#include "esp_http_server.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_wifi.h" +#include "esp_wifi_default.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "lwip/sockets.h" +#include "provision_validate.h" + +namespace { + +constexpr const char *TAG = "wifi_provision"; + +// Open, no password: this AP only exists because there's no other way in, +// so physical proximity (being close enough to see and join it) is the +// access control — gating it behind a secret would defeat its own purpose. +constexpr const char *AP_SSID = "edge-dns-setup"; + +constexpr size_t RX_BUFFER_SIZE = 512; +constexpr size_t TX_BUFFER_SIZE = 512; + +// The lwip DHCP server's "offer DNS server" bit (OFFER_DNS in +// dhcpserver.h). Defined locally rather than pulling in that header, same +// as the esp-idf softap_sta example — it's a single stable byte value, not +// worth a component dependency. +constexpr uint8_t DHCPS_OFFER_DNS = 0x02; + +// Set once in wifi_provision_start(), before the captive DNS task is +// created; read-only from then on, so no locking needed across the two +// tasks. +std::array s_ap_ip; + +constexpr const char *PORTAL_PAGE_BODY = R"HTML( + +edge-dns setup + +

Wi-Fi setup

+

Pick a network (or type one manually) and enter its password.

+
+
+
+
+ +
+

+ + + +)HTML"; + +const char *auth_mode_to_string(wifi_auth_mode_t mode) +{ + switch (mode) { + case WIFI_AUTH_OPEN: + return "open"; + case WIFI_AUTH_WEP: + return "wep"; + case WIFI_AUTH_WPA_PSK: + return "wpa"; + case WIFI_AUTH_WPA2_PSK: + return "wpa2"; + case WIFI_AUTH_WPA_WPA2_PSK: + return "wpa/wpa2"; + case WIFI_AUTH_WPA3_PSK: + return "wpa3"; + case WIFI_AUTH_WPA2_WPA3_PSK: + return "wpa2/wpa3"; + default: + return "other"; + } +} + +// Blocking scan (esp_wifi_scan_start's second arg) — briefly disrupts the +// AP, which is fine for a form the user only submits once, but is exactly +// why the portal only scans on page load rather than polling. +esp_err_t scan_get_handler(httpd_req_t *req) +{ + esp_err_t err = esp_wifi_scan_start(nullptr, true); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_scan_start failed: %s", esp_err_to_name(err)); + return httpd_resp_send_500(req); + } + + uint16_t ap_count = 0; + esp_wifi_scan_get_ap_num(&ap_count); + // Generous headroom for a bench network, not a real capacity need — a + // dense-apartment-building scan would just get truncated here. + constexpr uint16_t MAX_SCAN_RESULTS = 32; + ap_count = std::min(ap_count, MAX_SCAN_RESULTS); + + std::vector records(ap_count); + if (ap_count > 0) { + err = esp_wifi_scan_get_ap_records(&ap_count, records.data()); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_scan_get_ap_records failed: %s", esp_err_to_name(err)); + return httpd_resp_send_500(req); + } + records.resize(ap_count); + } + + // Dedup by SSID keeping the strongest RSSI seen — the same network is + // routinely heard on multiple channels/BSSIDs, and the picker only + // needs one entry per name. + std::vector deduped; + for (const auto &rec : records) { + if (rec.ssid[0] == '\0') { + continue; // hidden network: nothing to show, and not matchable by name later + } + auto it = std::find_if(deduped.begin(), deduped.end(), [&](const wifi_ap_record_t &d) { + return std::strncmp(reinterpret_cast(d.ssid), + reinterpret_cast(rec.ssid), sizeof(d.ssid)) == 0; + }); + if (it == deduped.end()) { + deduped.push_back(rec); + } else if (rec.rssi > it->rssi) { + *it = rec; + } + } + std::sort(deduped.begin(), deduped.end(), + [](const wifi_ap_record_t &a, const wifi_ap_record_t &b) { return a.rssi > b.rssi; }); + + cJSON *root = cJSON_CreateArray(); + if (root == nullptr) { + ESP_LOGE(TAG, "failed to allocate JSON array"); + return httpd_resp_send_500(req); + } + for (const auto &rec : deduped) { + cJSON *entry = cJSON_CreateObject(); + cJSON_AddStringToObject(entry, "ssid", reinterpret_cast(rec.ssid)); + cJSON_AddNumberToObject(entry, "rssi", rec.rssi); + cJSON_AddStringToObject(entry, "auth", auth_mode_to_string(rec.authmode)); + cJSON_AddItemToArray(root, entry); + } + + char *json_str = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (json_str == nullptr) { + ESP_LOGE(TAG, "failed to serialize scan JSON"); + return httpd_resp_send_500(req); + } + + httpd_resp_set_type(req, "application/json"); + esp_err_t ret = httpd_resp_send(req, json_str, HTTPD_RESP_USE_STRLEN); + cJSON_free(json_str); + return ret; +} + +constexpr httpd_uri_t SCAN_URI = { + .uri = "/scan", + .method = HTTP_GET, + .handler = scan_get_handler, + .user_ctx = nullptr, +}; + +// One-shot task: esp_restart() called directly from the handler would cut +// the httpd response off mid-flush, so the reboot happens here instead, +// after a short delay long enough for the response to actually reach the +// browser. +void provision_reboot_task(void *) +{ + vTaskDelay(pdMS_TO_TICKS(500)); + esp_restart(); +} + +esp_err_t provision_post_handler(httpd_req_t *req) +{ + // A {"ssid","password"} body is a handful of bytes; this is headroom, + // not a real capacity need — same reasoning as http_server.cpp's + // read_json_body cap. + if (req->content_len == 0 || req->content_len > 512) { + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "invalid body"); + } + std::string body(req->content_len, '\0'); + size_t received = 0; + while (received < req->content_len) { + int ret = httpd_req_recv(req, body.data() + received, req->content_len - received); + if (ret <= 0) { + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "failed to read body"); + } + received += static_cast(ret); + } + + cJSON *root = cJSON_ParseWithLength(body.c_str(), body.size()); + if (root == nullptr) { + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "invalid JSON body"); + } + cJSON *ssid_item = cJSON_GetObjectItemCaseSensitive(root, "ssid"); + cJSON *password_item = cJSON_GetObjectItemCaseSensitive(root, "password"); + if (!cJSON_IsString(ssid_item)) { + cJSON_Delete(root); + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "missing \"ssid\""); + } + std::string ssid = ssid_item->valuestring; + std::string password = cJSON_IsString(password_item) ? password_item->valuestring : ""; + cJSON_Delete(root); + + switch (provision_validate(ssid, password)) { + case ProvisionValidation::kSsidEmpty: + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "ssid must not be empty"); + case ProvisionValidation::kSsidTooLong: + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "ssid too long (max 32 bytes)"); + case ProvisionValidation::kPasswordTooShort: + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "password too short (min 8 bytes)"); + case ProvisionValidation::kPasswordTooLong: + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "password too long (max 63 bytes)"); + case ProvisionValidation::kOk: + break; + default: + // Belt-and-suspenders, same as the DnsRecordStoreResult switches in + // http_server.cpp: a future ProvisionValidation value added and + // missed here must not silently fall through to kOk's break and + // write an unvalidated config. + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "invalid ssid/password"); + } + + wifi_config_t sta_config = {}; + std::strncpy(reinterpret_cast(sta_config.sta.ssid), ssid.c_str(), + sizeof(sta_config.sta.ssid) - 1); + std::strncpy(reinterpret_cast(sta_config.sta.password), password.c_str(), + sizeof(sta_config.sta.password) - 1); + esp_err_t err = esp_wifi_set_config(WIFI_IF_STA, &sta_config); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_set_config failed: %s", esp_err_to_name(err)); + return httpd_resp_send_500(req); + } + + ESP_LOGI(TAG, "provisioned for '%s', rebooting into STA mode", ssid.c_str()); + esp_err_t send_err = httpd_resp_send(req, nullptr, 0); + xTaskCreate(provision_reboot_task, "provision_reboot", 2048, nullptr, tskIDLE_PRIORITY + 1, + nullptr); + return send_err; +} + +constexpr httpd_uri_t PROVISION_URI = { + .uri = "/provision", + .method = HTTP_POST, + .handler = provision_post_handler, + .user_ctx = nullptr, +}; + +// Matches every path (config.uri_match_fn = httpd_uri_match_wildcard, set +// in wifi_provision_start()) — OS captive-portal-detection probes hit +// arbitrary well-known paths, and all of them need to resolve to the +// portal for the OS to actually pop it up. +esp_err_t portal_get_handler(httpd_req_t *req) +{ + httpd_resp_set_type(req, "text/html"); + return httpd_resp_send(req, PORTAL_PAGE_BODY, HTTPD_RESP_USE_STRLEN); +} + +constexpr httpd_uri_t PORTAL_URI = { + .uri = "/*", + .method = HTTP_GET, + .handler = portal_get_handler, + .user_ctx = nullptr, +}; + +// Minimal captive DNS responder: every A query gets the AP's own IP (so a +// client that just resolved "connectivitycheck.something" lands on the +// portal), everything else gets NODATA. No cache, no forwarding, no +// metrics — there's no upstream to forward to in AP mode and nothing here +// is on the hot path dns_server.cpp optimizes for. +void captive_dns_task(void *) +{ + int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + if (sock < 0) { + ESP_LOGE(TAG, "socket() failed: errno %d", errno); + vTaskDelete(nullptr); + return; + } + + sockaddr_in dest_addr = {}; + dest_addr.sin_family = AF_INET; + dest_addr.sin_addr.s_addr = htonl(INADDR_ANY); + dest_addr.sin_port = htons(53); + if (bind(sock, reinterpret_cast(&dest_addr), sizeof(dest_addr)) < 0) { + ESP_LOGE(TAG, "bind() to port 53 failed: errno %d", errno); + close(sock); + vTaskDelete(nullptr); + return; + } + ESP_LOGI(TAG, "captive DNS listening on UDP port 53"); + + while (true) { + std::array rx_buffer; + sockaddr_in source_addr = {}; + socklen_t socklen = sizeof(source_addr); + int len = recvfrom(sock, rx_buffer.data(), rx_buffer.size(), 0, + reinterpret_cast(&source_addr), &socklen); + if (len < 0) { + ESP_LOGE(TAG, "recvfrom() failed: errno %d", errno); + continue; + } + + auto header = parse_dns_header(rx_buffer.data(), len); + if (!header || header->qdcount == 0) { + continue; + } + size_t offset = DNS_HEADER_SIZE; + auto qname = parse_question_name(rx_buffer.data(), len, offset); + if (!qname || offset + 4 > static_cast(len)) { + continue; + } + uint16_t qtype = read_uint16_be(rx_buffer.data(), offset); + const uint8_t *question_section = rx_buffer.data() + DNS_HEADER_SIZE; + size_t question_section_len = (offset + 4) - DNS_HEADER_SIZE; + + uint8_t tx_buffer[TX_BUFFER_SIZE]; + std::optional resp_len; + if (qtype == DNS_TYPE_A) { + resp_len = build_a_record_response(header->id, header->flags, question_section, + question_section_len, s_ap_ip, tx_buffer, + sizeof(tx_buffer)); + } else { + // AAAA (or anything else): NODATA, not an A answer and not + // NXDOMAIN — a bogus AAAA would make a dual-stack client prefer + // a v6 path to nowhere instead of following the v4 redirect. + resp_len = build_nodata_response(header->id, header->flags, question_section, + question_section_len, tx_buffer, sizeof(tx_buffer)); + } + if (resp_len) { + sendto(sock, tx_buffer, *resp_len, 0, reinterpret_cast(&source_addr), + socklen); + } + } +} + +// Polls GPIO0 (BOOT) every 250ms; 20 consecutive low reads (~5s) before +// acting, so a stray transient on the line can't trigger a wipe. +void factory_reset_task(void *) +{ + gpio_config_t io_conf = {}; + io_conf.pin_bit_mask = 1ULL << GPIO_NUM_0; + io_conf.mode = GPIO_MODE_INPUT; + io_conf.pull_up_en = GPIO_PULLUP_ENABLE; // most devkits already pull this up externally too + io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; + io_conf.intr_type = GPIO_INTR_DISABLE; + gpio_config(&io_conf); + + constexpr int REQUIRED_CONSECUTIVE_LOW = 20; + int consecutive_low = 0; + while (true) { + vTaskDelay(pdMS_TO_TICKS(250)); + if (gpio_get_level(GPIO_NUM_0) != 0) { + consecutive_low = 0; + continue; + } + if (++consecutive_low >= REQUIRED_CONSECUTIVE_LOW) { + ESP_LOGW(TAG, "BOOT held ~5s, wiping stored Wi-Fi config and rebooting"); + esp_wifi_restore(); + esp_restart(); + } + } +} + +} // namespace + +void wifi_provision_start() +{ + ESP_LOGW(TAG, "no provisioned network reachable, starting portal '%s'", AP_SSID); + + esp_netif_t *ap_netif = esp_netif_create_default_wifi_ap(); + + // Only reached after wifi_connect_sta() already started Wi-Fi in STA + // mode and its disconnect handler has been retrying esp_wifi_connect() + // in the background — reconfiguring mode/config on a running stack + // without stopping it first isn't a supported transition. Ignore + // ESP_ERR_WIFI_NOT_STARTED (nothing to stop if it somehow wasn't + // running); anything else is unexpected here and should abort like the + // rest of this bring-up sequence. + esp_err_t stop_err = esp_wifi_stop(); + if (stop_err != ESP_OK && stop_err != ESP_ERR_WIFI_NOT_STARTED) { + ESP_ERROR_CHECK(stop_err); + } + + // APSTA, not plain AP: esp_wifi_scan_start() (used by /scan) fails in + // WIFI_MODE_AP — nothing here ever actually associates via the STA + // interface, it's along for the ride purely so scanning works. + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA)); + + wifi_config_t ap_config = {}; + std::strncpy(reinterpret_cast(ap_config.ap.ssid), AP_SSID, + sizeof(ap_config.ap.ssid) - 1); + ap_config.ap.ssid_len = static_cast(std::strlen(AP_SSID)); + ap_config.ap.authmode = WIFI_AUTH_OPEN; + ap_config.ap.max_connection = 4; + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + + esp_netif_ip_info_t ip_info = {}; + ESP_ERROR_CHECK(esp_netif_get_ip_info(ap_netif, &ip_info)); + uint32_t addr = ip_info.ip.addr; + s_ap_ip = {static_cast(addr & 0xFF), static_cast((addr >> 8) & 0xFF), + static_cast((addr >> 16) & 0xFF), static_cast((addr >> 24) & 0xFF)}; + ESP_LOGI(TAG, "AP up at " IPSTR, IP2STR(&ip_info.ip)); + + // The AP's DHCP server doesn't hand out a DNS server by default. + // Without this, joining clients get no resolver at all, the captive + // DNS task below never sees a query, and no captive-portal probe ever + // fires — the DHCP server must be stopped while its options are + // reconfigured. + uint8_t dhcps_dns_offer = DHCPS_OFFER_DNS; + ESP_ERROR_CHECK(esp_netif_dhcps_stop(ap_netif)); + ESP_ERROR_CHECK(esp_netif_dhcps_option(ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_DOMAIN_NAME_SERVER, + &dhcps_dns_offer, sizeof(dhcps_dns_offer))); + esp_netif_dns_info_t dns_info = {}; + dns_info.ip.type = ESP_IPADDR_TYPE_V4; + dns_info.ip.u_addr.ip4 = ip_info.ip; + ESP_ERROR_CHECK(esp_netif_set_dns_info(ap_netif, ESP_NETIF_DNS_MAIN, &dns_info)); + ESP_ERROR_CHECK(esp_netif_dhcps_start(ap_netif)); + + // No factory_reset_task here: in AP mode the provisioning portal itself + // is already the recovery path (see factory_reset_watch_start()'s doc + // comment) — running the watcher too would just contend over GPIO0 for + // no benefit. + xTaskCreate(captive_dns_task, "captive_dns", 4096, nullptr, 5, nullptr); + + httpd_handle_t server = nullptr; + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.uri_match_fn = httpd_uri_match_wildcard; // required for the catch-all "/*" portal route + ESP_ERROR_CHECK(httpd_start(&server, &config)); + ESP_ERROR_CHECK(httpd_register_uri_handler(server, &SCAN_URI)); + ESP_ERROR_CHECK(httpd_register_uri_handler(server, &PROVISION_URI)); + ESP_ERROR_CHECK(httpd_register_uri_handler(server, &PORTAL_URI)); + + ESP_LOGI(TAG, "provisioning portal ready — join '%s' and browse to any URL", AP_SSID); +} + +void factory_reset_watch_start() +{ + xTaskCreate(factory_reset_task, "factory_reset_watch", 2048, nullptr, tskIDLE_PRIORITY + 1, + nullptr); +} diff --git a/main/wifi_provision.h b/main/wifi_provision.h new file mode 100644 index 0000000..89ede5b --- /dev/null +++ b/main/wifi_provision.h @@ -0,0 +1,19 @@ +#pragma once + +// Brings up the SoftAP captive-portal provisioning flow: AP "edge-dns-setup" +// (open, no password — physical proximity is the access control here), +// a captive DNS responder that answers every A query with the AP's own IP, +// and an httpd instance serving /scan, /provision, and a wildcard portal +// page. Called from main.cpp only when wifi_connect_sta() couldn't get an +// IP within its boot-time timeout. Returns after spawning the DNS task and +// httpd instance — both run independently, same convention as +// dns_server_start(). +void wifi_provision_start(); + +// Starts a background task that watches GPIO0 (BOOT) for a ~5s hold and, on +// seeing one, wipes the stored Wi-Fi config (esp_wifi_restore()) and +// reboots — the recovery path for "provisioned with the wrong password" when +// there's no cable handy. STA mode only: in AP mode the provisioning portal +// already *is* the recovery path, and there's no reason to contend with +// anything else that might want GPIO0. +void factory_reset_watch_start();