From 4454e9a8a5305aa9feb1c32228982c0c7ff30f8c Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Sat, 9 May 2026 10:48:24 -0400 Subject: [PATCH] diag(provisioning): instrument captive flow + tighten DHCP/radio behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous protocol fix (302 → 200 portal HTML) didn't restore iOS captive-banner reliability under the lock-screen-camera join: the user joined, accepted the prompt, and got nothing on unlock. We're guessing without data, so this round adds instrumentation alongside three high-confidence behavioral fixes that are individually plausible explanations. Fixes: * Force the AP DHCP server to advertise the AP IP as DNS via esp_netif_dhcps_option(ESP_NETIF_DOMAIN_NAME_SERVER). Arduino-ESP32's softAP doesn't set this explicitly; if a client comes in with cached cellular DNS the captive DNS hijack gets bypassed and iOS resolves captive.apple.com to real internet — no captive signal ever fires. * WiFi.setSleep(false) so the AP radio doesn't park between beacons and drop probe packets that arrive during a sleep window. * Cache-Control: no-store on the portal response, so iOS doesn't carry a "this SSID was fine last time" determination across forget+rejoin cycles. Diagnostics (logged on serial at 115200, in AP mode only): * Every HTTP request: method, URI, Host, User-Agent. Tells us whether iOS is reaching us and which CNA path it's hitting. * WiFi AP events: STA-associated, IP-assigned, STA-disconnected. Tells us whether the join completed and DHCP succeeded. Repro: pio device monitor -e waveshare73-v1, forget the network on the phone, lock + scan + accept, watch the log. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main.cpp | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/src/main.cpp b/src/main.cpp index ed8c9fa..e0f343a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,6 +8,7 @@ #include #include #include +#include "esp_netif.h" #include "config.h" #include "epd.h" #include "operation.h" @@ -97,11 +98,37 @@ min-height:100vh;margin:0;background:#fdf6ee;color:#3a2e22;text-align:center} // ── Web server handlers ─────────────────────────────────────────────────────── +// Diagnostic — every probe we receive during provisioning gets logged +// with method / URI / Host / User-Agent so a serial-monitor repro tells +// us whether the client (iOS in particular) is actually hitting us, and +// which CNA path it's using. Cheap; only runs in AP mode. +static void log_provisioning_request() { + String method = server.method() == HTTP_GET ? "GET" + : server.method() == HTTP_POST ? "POST" + : server.method() == HTTP_HEAD ? "HEAD" + : "OTHER"; + Serial.print("[ap-http] "); + Serial.print(method); + Serial.print(" "); + Serial.print(server.uri()); + Serial.print(" host="); + Serial.print(server.hostHeader()); + Serial.print(" ua="); + Serial.println(server.header("User-Agent")); +} + static void handle_root() { + log_provisioning_request(); + // no-store stops iOS from caching a CNA result across joins to the + // same SSID name (e.g. after a forget+rejoin cycle) — we always want + // the captive banner to fire, never the cached "this network was + // fine last time" answer. + server.sendHeader("Cache-Control", "no-store, must-revalidate"); server.send_P(200, "text/html", PORTAL_HTML); } static void handle_connect() { + log_provisioning_request(); if (!server.hasArg("ssid") || server.arg("ssid").isEmpty()) { server.send(400, "text/plain", "Missing ssid"); return; @@ -112,6 +139,25 @@ static void handle_connect() { server.send_P(200, "text/html", CONNECTING_HTML); } +static void on_ap_event(WiFiEvent_t event, WiFiEventInfo_t info) { + switch (event) { + case ARDUINO_EVENT_WIFI_AP_START: + Serial.println("[ap-event] AP started"); + break; + case ARDUINO_EVENT_WIFI_AP_STACONNECTED: + Serial.println("[ap-event] station associated"); + break; + case ARDUINO_EVENT_WIFI_AP_STAIPASSIGNED: + Serial.println("[ap-event] DHCP lease handed out"); + break; + case ARDUINO_EVENT_WIFI_AP_STADISCONNECTED: + Serial.println("[ap-event] station disconnected"); + break; + default: + break; + } +} + // ── WiFi provisioning ───────────────────────────────────────────────────────── static void enter_provisioning(const String& mac, bool retry = false) { @@ -147,10 +193,46 @@ static void enter_provisioning(const String& mac, bool retry = false) { server.on("/connecttest.txt", handle_root); // Windows 10+ server.onNotFound(handle_root); + // WebServer doesn't capture arbitrary request headers unless told to + const char* tracked_headers[] = { "User-Agent", "Host" }; + server.collectHeaders(tracked_headers, 2); + + WiFi.onEvent(on_ap_event); WiFi.disconnect(true); WiFi.mode(WIFI_AP); WiFi.softAP(apSsid.c_str()); + // Power-save can park the radio between beacons and drop iOS CNA + // probes that arrive during a sleep window — keep the AP fully awake + // so the captive-detect window is deterministic. + WiFi.setSleep(false); + + // Belt-and-braces DHCP DNS advertisement: ESP-IDF's softAP DHCP server + // *should* offer the AP IP as DNS by default, but if the client comes + // in with cached cellular DNS (8.8.8.8 etc) the captive DNS hijack + // gets bypassed entirely and iOS resolves captive.apple.com to the + // real internet. Stopping the DHCP server, setting DNS-info, flipping + // the offer-DNS flag, then restarting forces every lease to carry our + // address as the primary resolver. + esp_netif_t *ap_netif = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"); + if (ap_netif) { + esp_netif_dhcps_stop(ap_netif); + + esp_netif_ip_info_t ip; + esp_netif_get_ip_info(ap_netif, &ip); + esp_netif_dns_info_t dns_info = {}; + dns_info.ip.u_addr.ip4.addr = ip.ip.addr; + dns_info.ip.type = ESP_IPADDR_TYPE_V4; + esp_netif_set_dns_info(ap_netif, ESP_NETIF_DNS_MAIN, &dns_info); + + uint8_t offer_dns = 1; + esp_netif_dhcps_option(ap_netif, ESP_NETIF_OP_SET, + ESP_NETIF_DOMAIN_NAME_SERVER, + &offer_dns, sizeof(offer_dns)); + + esp_netif_dhcps_start(ap_netif); + } + server.begin(); dns.setErrorReplyCode(DNSReplyCode::NoError); dns.start(53, "*", WiFi.softAPIP());