diag(provisioning): instrument captive flow + tighten DHCP/radio behavior
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) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
#include <Preferences.h>
|
#include <Preferences.h>
|
||||||
#include <LittleFS.h>
|
#include <LittleFS.h>
|
||||||
#include <qrcode.h>
|
#include <qrcode.h>
|
||||||
|
#include "esp_netif.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "epd.h"
|
#include "epd.h"
|
||||||
#include "operation.h"
|
#include "operation.h"
|
||||||
@@ -97,11 +98,37 @@ min-height:100vh;margin:0;background:#fdf6ee;color:#3a2e22;text-align:center}
|
|||||||
|
|
||||||
// ── Web server handlers ───────────────────────────────────────────────────────
|
// ── 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() {
|
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);
|
server.send_P(200, "text/html", PORTAL_HTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void handle_connect() {
|
static void handle_connect() {
|
||||||
|
log_provisioning_request();
|
||||||
if (!server.hasArg("ssid") || server.arg("ssid").isEmpty()) {
|
if (!server.hasArg("ssid") || server.arg("ssid").isEmpty()) {
|
||||||
server.send(400, "text/plain", "Missing ssid");
|
server.send(400, "text/plain", "Missing ssid");
|
||||||
return;
|
return;
|
||||||
@@ -112,6 +139,25 @@ static void handle_connect() {
|
|||||||
server.send_P(200, "text/html", CONNECTING_HTML);
|
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 ─────────────────────────────────────────────────────────
|
// ── WiFi provisioning ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
static void enter_provisioning(const String& mac, bool retry = false) {
|
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.on("/connecttest.txt", handle_root); // Windows 10+
|
||||||
server.onNotFound(handle_root);
|
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.disconnect(true);
|
||||||
WiFi.mode(WIFI_AP);
|
WiFi.mode(WIFI_AP);
|
||||||
WiFi.softAP(apSsid.c_str());
|
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();
|
server.begin();
|
||||||
dns.setErrorReplyCode(DNSReplyCode::NoError);
|
dns.setErrorReplyCode(DNSReplyCode::NoError);
|
||||||
dns.start(53, "*", WiFi.softAPIP());
|
dns.start(53, "*", WiFi.softAPIP());
|
||||||
|
|||||||
Reference in New Issue
Block a user