feat(webconfig): implement web configuration portal for ESP32

This commit is contained in:
agessaman committed 2026-07-16 17:00:22 -07:00
1 parent d7a7e1b642
commit dfee21a0c9
16 files changed
+2460 -3

No files matched your search

+12
View File
@@ -343,6 +343,18 @@ public:
return false;
};
// Browser-based config portal (ESP32 WITH_MQTT_BRIDGE builds override).
// force_ap=true requests the SoftAP setup portal even when WiFi is configured.
// Returns true if handled (reply filled either way when true).
virtual bool startWebConfig(bool force_ap, char* reply) {
(void)force_ap; (void)reply;
return false;
};
virtual bool stopWebConfig(char* reply) {
(void)reply;
return false;
};
// Probe all configured NTP servers for connectivity (verbose=serial console gets a
// detailed table; otherwise reply gets a compact "<server> ok|fail" list).
virtual bool runMqttNtpDiag(char* reply, size_t reply_size, bool verbose) {
+15
View File
@@ -979,6 +979,21 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command,
strcpy(reply, "ERR: online OTA not supported on this build");
#endif
return true;
} else if (memcmp(command, "start webconfig", 15) == 0 && (command[15] == 0 || command[15] == ' ')) {
// Web config portal: `start webconfig` binds to the LAN IP (or raises the
// setup AP when WiFi is unconfigured); `start webconfig ap` forces the AP.
bool force_ap = (command[15] == ' ' && strcmp(&command[16], "ap") == 0);
if (command[15] == ' ' && !force_ap) {
strcpy(reply, "ERR: usage start webconfig [ap]");
} else if (!_callbacks->startWebConfig(force_ap, reply)) {
strcpy(reply, "ERR: webconfig not supported on this build");
}
return true;
} else if (strcmp(command, "stop webconfig") == 0) {
if (!_callbacks->stopWebConfig(reply)) {
strcpy(reply, "ERR: webconfig not supported on this build");
}
return true;
} else if (memcmp(command, "alert test", 10) == 0 && (command[10] == 0 || command[10] == ' ')) {
// Send a one-off test alert on the configured alert channel.
const char* extra = command[10] == ' ' ? &command[11] : "";
+31
View File
@@ -289,6 +289,37 @@ void MQTTBridge::formatMqttStatsReply(char* buf, size_t bufsize) {
}
}
// Structured per-slot status for the webconfig stats endpoint. Same state
// derivation as formatMqttStatusReply above. Returns false for out-of-range,
// unconfigured, or bridge-not-running slots.
bool MQTTBridge::getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out) {
if (out == nullptr || slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return false;
if (s_mqtt_bridge_instance == nullptr || !s_mqtt_bridge_instance->_initialized) return false;
MQTTBridge* b = s_mqtt_bridge_instance;
const MQTTSlot& slot = b->_slots[slot_index];
if (!slot.enabled && slot.preset) {
out->name = slot.preset->name;
out->state = "inactive";
} else if (!slot.enabled) {
return false; // unconfigured slot
} else {
out->name = slot.preset ? slot.preset->name : "custom";
if (!b->isSlotReady(slot_index)) {
out->state = "wait";
} else if (slot.connected) {
out->state = "ok";
} else if (slot.circuit_breaker_tripped) {
out->state = "fail";
} else {
out->state = "disc";
}
}
out->publish_ok = slot.client ? slot.client->getPublishOk() : 0;
out->publish_err = slot.client ? slot.client->getPublishErr() : 0;
return true;
}
uint8_t MQTTBridge::getLastWifiDisconnectReason() { return s_wifi_disconnect_reason; }
unsigned long MQTTBridge::getLastWifiDisconnectTime() { return s_wifi_disconnect_time; }
+11
View File
@@ -471,6 +471,17 @@ public:
/** On-demand publish-health + heap snapshot for `get mqtt.stats` (per-slot ok/err,
* outbox size, free/max heap, queue depth). */
static void formatMqttStatsReply(char* buf, size_t bufsize);
/** Structured per-slot snapshot for the webconfig stats endpoint. Same state
* logic as formatMqttStatusReply, same cross-task read semantics. Returns
* false when the bridge is not running, the index is out of range, or the
* slot is unconfigured (skip it). */
struct SlotStatusSnapshot {
const char* name; // preset name or "custom"
const char* state; // "inactive" | "wait" | "ok" | "fail" | "disc"
unsigned long publish_ok;
unsigned long publish_err;
};
static bool getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out);
/** True when WiFi is set and at least one MQTT slot can run (preset + custom host if needed). */
static bool isConfigValid(const MQTTPrefs* obs);
static void formatSlotDiagReply(char* buf, size_t bufsize, int slot_index);
+760
View File
@@ -0,0 +1,760 @@
#if defined(ESP_PLATFORM) && defined(WITH_MQTT_BRIDGE)
#include "WebConfigServer.h"
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <DNSServer.h>
#include <ArduinoJson.h>
#include <esp_system.h>
#include <esp_heap_caps.h>
#include <helpers/CommonCLI.h>
#include <helpers/MQTTPresets.h>
#include <helpers/bridges/MQTTBridge.h>
#include "WebConfigHtml.h"
// Placeholder sent instead of stored secrets; POSTs carrying it are dropped
// so an untouched password field never overwrites the stored value.
static const char SECRET_SENTINEL[] = "********";
// Keys the web UI may drive through the CLI `set` handlers. Everything else
// is rejected, so a crafted request can't reach arbitrary commands (`erase`,
// `password`, ...) through the batch.
static const char* const ALLOWED_SET_KEYS[] = {
// NodePrefs (radio / node)
"name", "lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay",
"cad", "radio.rxgain", "advert.interval", "flood.advert.interval",
// MQTTPrefs (WiFi / MQTT / misc observer)
"wifi.ssid", "wifi.pwd", "wifi.powersave",
"mqtt.origin", "mqtt.iata", "mqtt.status", "mqtt.packets", "mqtt.raw",
"mqtt.tx", "mqtt.rx", "mqtt.interval", "mqtt.ntp", "mqtt.owner", "mqtt.email",
"timezone", "timezone.offset", "snmp", "snmp.community",
};
static const char* const ALLOWED_SLOT_KEYS[] = {
"preset", "server", "port", "username", "password", "token", "topic", "audience",
};
static bool isAllowedSetKey(const char* key) {
for (size_t i = 0; i < sizeof(ALLOWED_SET_KEYS) / sizeof(ALLOWED_SET_KEYS[0]); i++) {
if (strcmp(key, ALLOWED_SET_KEYS[i]) == 0) return true;
}
// mqtt<1-6>.<field>
if (memcmp(key, "mqtt", 4) == 0 && key[4] >= '1' && key[4] <= ('0' + MAX_MQTT_SLOTS)
&& key[5] == '.') {
for (size_t i = 0; i < sizeof(ALLOWED_SLOT_KEYS) / sizeof(ALLOWED_SLOT_KEYS[0]); i++) {
if (strcmp(&key[6], ALLOWED_SLOT_KEYS[i]) == 0) return true;
}
}
return false;
}
static bool isSecretKey(const char* key) {
if (strcmp(key, "wifi.pwd") == 0) return true;
if (memcmp(key, "mqtt", 4) == 0 && key[5] == '.'
&& (strcmp(&key[6], "password") == 0 || strcmp(&key[6], "token") == 0)) return true;
return false;
}
// Constant-time-ish comparison so login timing doesn't leak a prefix match.
static bool fixedTimeEquals(const char* a, const char* b, size_t max_len) {
size_t la = strnlen(a, max_len), lb = strnlen(b, max_len);
uint8_t diff = (la == lb) ? 0 : 1;
for (size_t i = 0; i < max_len; i++) {
char ca = (i < la) ? a[i] : 0;
char cb = (i < lb) ? b[i] : 0;
diff |= (uint8_t)(ca ^ cb);
}
return diff == 0;
}
// RAII lock; tolerates a null mutex (allocation failure) by not locking.
struct WCLock {
SemaphoreHandle_t h;
explicit WCLock(SemaphoreHandle_t s) : h(s) { if (h) xSemaphoreTake(h, portMAX_DELAY); }
~WCLock() { if (h) xSemaphoreGive(h); }
};
WebConfigServer* WebConfigServer::_active = NULL;
WebConfigServer::WebConfigServer(NodePrefs* prefs, MQTTPrefs* obs, Callbacks* callbacks,
const uint8_t* pub_key, const char* fw_ver,
const char* role, const char* board_name)
: _prefs(prefs), _obs(obs), _cb(callbacks), _pub_key(pub_key),
_fw_ver(fw_ver), _role(role), _board_name(board_name) {
_mux = xSemaphoreCreateMutex();
_active = this;
}
WebConfigServer::~WebConfigServer() {
if (_active == this) _active = NULL;
if (_mux) vSemaphoreDelete(_mux);
}
bool WebConfigServer::isRebootPending() {
WebConfigServer* w = _active;
return w != NULL && w->_reboot_at != 0 && w->_batch_reboot &&
w->_batch_state == BATCH_DONE;
}
bool WebConfigServer::getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t ip_len) {
WebConfigServer* w = _active;
if (w == NULL || w->_mode != MODE_SETUP || w->_stopping) return false;
if (ssid && ssid_len > 0) {
strncpy(ssid, w->_ap_ssid, ssid_len - 1);
ssid[ssid_len - 1] = 0;
}
if (ip && ip_len > 0) {
snprintf(ip, ip_len, "%s", WiFi.softAPIP().toString().c_str());
}
return true;
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
bool WebConfigServer::startSetupMode(char reply[]) {
if (_mode != MODE_OFF || _stopping) {
strcpy(reply, "Err: webconfig busy");
return false;
}
// AP_STA (not pure AP) so the WiFi scan for the SSID picker works while
// the AP is up. STA stays unconnected - the bridge won't touch WiFi
// while wifi_ssid is empty, and `start webconfig ap` requires it stopped.
WiFi.mode(WIFI_AP_STA);
snprintf(_ap_ssid, sizeof(_ap_ssid), "MeshCore-Setup-%02X%02X", _pub_key[0], _pub_key[1]);
#ifdef WEBCONFIG_AP_PASSWORD
bool ap_ok = WiFi.softAP(_ap_ssid, WEBCONFIG_AP_PASSWORD);
#else
bool ap_ok = WiFi.softAP(_ap_ssid);
#endif
if (!ap_ok) {
WiFi.mode(WIFI_OFF);
strcpy(reply, "Err: failed to start AP");
return false;
}
delay(100); // let the AP netif settle before reading its IP
IPAddress ip = WiFi.softAPIP();
_dns = new DNSServer();
_dns->start(53, "*", ip); // captive portal: every name resolves to us
createServer();
_mode = MODE_SETUP;
_was_setup_ap = true;
_last_activity = millis();
WiFi.scanNetworks(true); // pre-populate the SSID picker
sprintf(reply, "WebConfig AP started: join '%s' then open http://%s/", _ap_ssid, ip.toString().c_str());
return true;
}
bool WebConfigServer::startLanMode(char reply[]) {
if (_mode != MODE_OFF || _stopping) {
strcpy(reply, "Err: webconfig busy");
return false;
}
if (WiFi.status() != WL_CONNECTED) {
strcpy(reply, "Err: WiFi not connected");
return false;
}
createServer();
_mode = MODE_LAN;
_last_activity = millis();
int pos = sprintf(reply, "WebConfig started: http://%s/ (admin password login)",
WiFi.localIP().toString().c_str());
if (heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL) < 60 * 1024) {
sprintf(reply + pos, " WARN: low heap");
}
return true;
}
void WebConfigServer::createServer() {
_server = new AsyncWebServer(80);
// iOS caches plain-HTTP GETs aggressively (keyed by URL, surviving even a
// device reflash behind the same IP), which poisons /api/config/result and
// friends with stale responses from earlier sessions. Forbid caching on
// every response; the HTML is small enough to refetch per visit.
DefaultHeaders::Instance().addHeader("Cache-Control", "no-store");
registerRoutes();
_server->begin();
}
void WebConfigServer::requestStop() {
if (_mode == MODE_OFF && !_stopping) return;
if (_server) _server->end();
if (_dns) _dns->stop();
_mode = MODE_OFF;
_stopping = true;
// Deleting an AsyncWebServer with live connections is a known crash source;
// give in-flight responses a grace period before freeing.
_delete_at = millis() + 2000;
if (_delete_at == 0) _delete_at = 1;
}
void WebConfigServer::finalizeTeardown() {
delete _server;
_server = NULL;
delete _dns;
_dns = NULL;
if (_was_setup_ap) {
WiFi.softAPdisconnect(true);
// Nothing else owns WiFi when we raised the AP: either the node is
// unconfigured, or `start webconfig ap` required the bridge stopped.
if (_obs->wifi_ssid[0] == 0) {
WiFi.mode(WIFI_OFF);
} else {
WiFi.mode(WIFI_STA);
}
_was_setup_ap = false;
}
_stopping = false;
_delete_at = 0;
_reboot_at = 0;
_batch_state = BATCH_IDLE;
_batch_next = 0;
_batch_reboot_armed = false;
_session_token[0] = 0;
_stats_json[0] = 0;
if (_cb) _cb->onWebConfigStopped();
}
void WebConfigServer::tick(uint32_t now) {
if (_stopping) {
if (_delete_at && (int32_t)(now - _delete_at) >= 0) finalizeTeardown();
return;
}
if (_mode == MODE_OFF) return;
if (_dns) _dns->processNextRequest();
if (_batch_state == BATCH_PENDING) drainBatch(now);
if (_reboot_at && (int32_t)(now - _reboot_at) >= 0) {
Serial.printf("WC: rebooting now (%s)\n", _batch_reboot_armed ? "confirmed" : "fallback");
_cb->rebootNow(); // does not return
}
if ((int32_t)(_diag_until - now) > 0 && (now - _diag_last) >= 1000) {
_diag_last = now;
Serial.printf("WC: diag sta=%d heap=%u batch=%d/%d state=%d\n",
(int)WiFi.softAPgetStationNum(), (unsigned)ESP.getFreeHeap(),
(int)_batch_next, (int)_batch_count, (int)_batch_state);
}
// Refresh the stats snapshot only while a client is actually polling.
if ((int32_t)(_stats_wanted_until - now) > 0 && (now - _stats_built_at) >= 2000) {
WCLock lock(_mux);
_cb->buildStatsJson(_stats_json, sizeof(_stats_json));
_stats_built_at = now;
}
// Idle timeout: only the setup AP auto-stops (a deployed node must not be
// left broadcasting an open AP). LAN mode runs until `stop webconfig`.
if (_mode == MODE_SETUP && WiFi.softAPgetStationNum() == 0 &&
(now - _last_activity) > WEBCONFIG_AP_IDLE_TIMEOUT_MS) {
requestStop();
}
}
void WebConfigServer::drainBatch(uint32_t now) {
// One command per call, spaced out. Each `set` persists prefs with a flash
// write, and flash writes stall the WiFi task (flash cache off); running a
// whole batch back-to-back starves the softAP of beacons long enough for
// clients (iPhones especially) to drop off mid-save.
if (_batch_next == 0) {
_cb->onConfigBatchStart();
} else if (_batch_next < _batch_count && (int32_t)(now - _batch_last_cmd) < 25) {
return; // let the WiFi task breathe between flash writes
}
if (_batch_next < _batch_count) {
BatchEntry& e = _batch[_batch_next++];
e.reply[0] = 0;
uint32_t t0 = millis();
_cb->execCommand(e.cmd, e.reply);
if (e.reply[0] == 0) strcpy(e.reply, "OK");
_batch_last_cmd = millis();
Serial.printf("WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next, (int)_batch_count,
e.key, (unsigned long)(_batch_last_cmd - t0));
if (_batch_next < _batch_count) return; // more commands next tick
}
_cb->onConfigBatchEnd();
WCLock lock(_mux);
_batch_state = BATCH_DONE;
if (_batch_reboot) {
// Fallback only: the real 3 s reboot timer is armed when the client reads
// /api/config/result (handleConfigResult), so the browser gets its
// confirmation before the AP/WiFi drops. This covers a client that
// disconnected and never polls — generous enough for a phone that got
// bounced off the AP mid-save to rejoin and fetch its confirmation.
_reboot_at = now + 30000;
if (_reboot_at == 0) _reboot_at = 1;
}
}
// ---------------------------------------------------------------------------
// Routes / auth
// ---------------------------------------------------------------------------
// Diagnostic trace of every request that reaches the server (async_tcp task).
// Distinguishes "client stopped sending" from "server stopped accepting" when
// a save's confirmation polls go missing on hardware.
static void wcLogReq(AsyncWebServerRequest* r) {
Serial.printf("WC: http %s %s\n", r->methodToString(), r->url().c_str());
}
void WebConfigServer::registerRoutes() {
_server->on("/", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleRoot(r); });
_server->on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleStatus(r); });
_server->on("/api/presets", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handlePresets(r); });
_server->on("/api/login", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleLogin(r); },
NULL, collectBody);
_server->on("/api/logout", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleLogout(r); });
// NB: plain-string routes match sub-paths too ("/api/config" matches
// "/api/config/result") and handlers run in registration order, so the more
// specific route MUST be registered first or it never fires. This was why
// save confirmations were lost: result polls were answered with config JSON.
_server->on("/api/config/result", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleConfigResult(r); });
_server->on("/api/config", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleConfigGet(r); });
_server->on("/api/config", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleConfigPost(r); },
NULL, collectBody);
_server->on("/api/stats", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleStats(r); });
_server->on("/api/scan", HTTP_GET, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleScan(r); });
_server->on("/api/reboot", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handleReboot(r); });
_server->on("/api/portal/exit", HTTP_POST, [this](AsyncWebServerRequest* r) { wcLogReq(r); handlePortalExit(r); });
_server->onNotFound([this](AsyncWebServerRequest* r) { wcLogReq(r); handleNotFound(r); });
}
// Accumulate a small JSON body into request->_tempObject (freed automatically
// by the request destructor). Oversized bodies are left unbuffered and
// rejected in the completion handler.
void WebConfigServer::collectBody(AsyncWebServerRequest* req, uint8_t* data, size_t len,
size_t index, size_t total) {
if (total == 0 || total > MAX_BODY) return;
if (index == 0) {
req->_tempObject = malloc(total + 1);
if (req->_tempObject) ((char*)req->_tempObject)[total] = 0;
}
if (req->_tempObject) memcpy((uint8_t*)req->_tempObject + index, data, len);
}
bool WebConfigServer::checkAuth(AsyncWebServerRequest* req) {
_last_activity = millis();
if (_mode == MODE_SETUP) return true; // physical proximity implied, nothing configured
if (_mode != MODE_LAN) return false;
if (_session_token[0] == 0) return false;
if (!req->hasHeader("Cookie")) return false;
const String& cookies = req->getHeader("Cookie")->value();
int idx = cookies.indexOf("wcs=");
if (idx < 0 || (int)cookies.length() < idx + 4 + 32) return false;
String token = cookies.substring(idx + 4, idx + 4 + 32);
uint32_t now = millis();
if ((uint32_t)(now - _session_last_seen) > WEBCONFIG_SESSION_TTL_MS) return false;
if (!fixedTimeEquals(token.c_str(), _session_token, 32)) return false;
_session_last_seen = now; // sliding expiry
return true;
}
// ---------------------------------------------------------------------------
// Handlers (async_tcp task - no CLI/prefs writes, no radio access)
// ---------------------------------------------------------------------------
void WebConfigServer::handleRoot(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
_last_activity = millis();
if (req->hasHeader("If-None-Match") &&
req->getHeader("If-None-Match")->value() == WEBCONFIG_HTML_ETAG) {
req->send(304);
return;
}
AsyncWebServerResponse* res =
req->beginResponse(200, "text/html", WEBCONFIG_HTML_GZ, WEBCONFIG_HTML_GZ_LEN);
res->addHeader("Content-Encoding", "gzip");
res->addHeader("ETag", WEBCONFIG_HTML_ETAG);
req->send(res);
}
void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
bool authed = checkAuth(req);
DynamicJsonDocument doc(512);
doc["mode"] = (_mode == MODE_SETUP) ? "setup" : "lan";
doc["auth"] = authed;
doc["needs_setup"] = (_obs->wifi_ssid[0] == 0);
doc["name"] = (const char*)_prefs->node_name;
char node_id[17];
for (int i = 0; i < 8; i++) sprintf(&node_id[i * 2], "%02x", _pub_key[i]);
doc["node_id"] = node_id;
doc["fw"] = _fw_ver;
doc["role"] = _role;
doc["board"] = _board_name;
doc["uptime_s"] = millis() / 1000;
doc["runtime_slots"] = RUNTIME_MQTT_SLOTS;
doc["max_slots"] = MAX_MQTT_SLOTS;
AsyncResponseStream* res = req->beginResponseStream("application/json");
serializeJson(doc, *res);
req->send(res);
}
void WebConfigServer::handleLogin(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
_last_activity = millis();
if (_mode == MODE_SETUP) { // no auth in setup mode
req->send(200, "application/json", "{\"ok\":true}");
return;
}
uint32_t now = millis();
if (_login_lock_until && (int32_t)(now - _login_lock_until) < 0) {
req->send(429, "application/json", "{\"error\":\"locked, retry in 30s\"}");
return;
}
const char* body = (const char*)req->_tempObject;
DynamicJsonDocument doc(256);
if (!body || deserializeJson(doc, body) != DeserializationError::Ok) {
req->send(400, "application/json", "{\"error\":\"bad request\"}");
return;
}
const char* pwd = doc["password"] | "";
if (!fixedTimeEquals(pwd, _prefs->password, sizeof(_prefs->password))) {
if (++_login_fails >= 5) {
_login_lock_until = now + 30000;
if (_login_lock_until == 0) _login_lock_until = 1;
_login_fails = 0;
}
req->send(401, "application/json", "{\"error\":\"wrong password\"}");
return;
}
_login_fails = 0;
_login_lock_until = 0;
for (int i = 0; i < 4; i++) sprintf(&_session_token[i * 8], "%08lx", (unsigned long)esp_random());
_session_last_seen = now;
AsyncWebServerResponse* res = req->beginResponse(200, "application/json", "{\"ok\":true}");
char cookie[80];
sprintf(cookie, "wcs=%s; HttpOnly; SameSite=Lax; Path=/", _session_token);
res->addHeader("Set-Cookie", cookie);
req->send(res);
}
void WebConfigServer::handleLogout(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
_session_token[0] = 0;
AsyncWebServerResponse* res = req->beginResponse(200, "application/json", "{\"ok\":true}");
res->addHeader("Set-Cookie", "wcs=; Max-Age=0; Path=/");
req->send(res);
}
void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
DynamicJsonDocument doc(6144);
{
WCLock lock(_mux);
JsonObject radio = doc.createNestedObject("radio");
// round via double so float error doesn't leak into the JSON
// (910.525f would otherwise serialize as 910.5250244)
radio["freq"] = (double)roundf(_prefs->freq * 1000.0f) / 1000.0;
radio["bw"] = (double)roundf(_prefs->bw * 100.0f) / 100.0;
radio["sf"] = _prefs->sf;
radio["cr"] = _prefs->cr;
radio["tx"] = _prefs->tx_power_dbm;
radio["af"] = _prefs->airtime_factor;
radio["rxdelay"] = _prefs->rx_delay_base;
radio["txdelay"] = _prefs->tx_delay_factor;
radio["cad"] = (bool)_prefs->cad_enabled;
radio["rxgain"] = (bool)_prefs->rx_boosted_gain;
radio["name"] = (const char*)_prefs->node_name;
radio["lat"] = _prefs->node_lat;
radio["lon"] = _prefs->node_lon;
radio["advert_interval"] = _prefs->advert_interval * 2; // stored as mins/2
radio["flood_advert_interval"] = _prefs->flood_advert_interval; // hours
JsonObject wifi = doc.createNestedObject("wifi");
wifi["ssid"] = (const char*)_obs->wifi_ssid;
wifi["pwd"] = _obs->wifi_password[0] ? SECRET_SENTINEL : "";
wifi["powersave"] = _obs->wifi_power_save == 0 ? "min"
: _obs->wifi_power_save == 2 ? "max" : "none";
JsonObject mqtt = doc.createNestedObject("mqtt");
mqtt["origin"] = (const char*)_obs->mqtt_origin;
mqtt["iata"] = (const char*)_obs->mqtt_iata;
mqtt["status"] = (bool)_obs->mqtt_status_enabled;
mqtt["packets"] = (bool)_obs->mqtt_packets_enabled;
mqtt["raw"] = (bool)_obs->mqtt_raw_enabled;
mqtt["tx"] = _obs->mqtt_tx_enabled == 2 ? "advert"
: _obs->mqtt_tx_enabled == 1 ? "on" : "off";
mqtt["rx"] = (bool)_obs->mqtt_rx_enabled;
mqtt["interval"] = _obs->mqtt_status_interval / 60000; // CLI takes minutes
mqtt["timezone"] = (const char*)_obs->timezone_string;
mqtt["timezone_offset"] = _obs->timezone_offset;
mqtt["ntp"] = (const char*)_obs->mqtt_ntp_server;
mqtt["owner"] = (const char*)_obs->mqtt_owner_public_key;
mqtt["email"] = (const char*)_obs->mqtt_email;
mqtt["snmp"] = (bool)_obs->snmp_enabled;
mqtt["snmp_community"] = (const char*)_obs->snmp_community;
JsonArray slots = mqtt.createNestedArray("slots");
for (int i = 0; i < MAX_MQTT_SLOTS; i++) {
JsonObject s = slots.createNestedObject();
s["preset"] = (const char*)_obs->mqtt_slot_preset[i];
s["server"] = (const char*)_obs->mqtt_slot_host[i];
s["port"] = _obs->mqtt_slot_port[i];
s["username"] = (const char*)_obs->mqtt_slot_username[i];
s["password"] = _obs->mqtt_slot_password[i][0] ? SECRET_SENTINEL : "";
s["token"] = _obs->mqtt_slot_token[i][0] ? SECRET_SENTINEL : "";
s["topic"] = (const char*)_obs->mqtt_slot_topic[i];
s["audience"] = (const char*)_obs->mqtt_slot_audience[i];
}
}
AsyncResponseStream* res = req->beginResponseStream("application/json");
serializeJson(doc, *res);
req->send(res);
}
void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
if (req->contentLength() > MAX_BODY) {
req->send(413, "application/json", "{\"error\":\"body too large\"}");
return;
}
const char* body = (const char*)req->_tempObject;
DynamicJsonDocument doc(6144);
if (!body || deserializeJson(doc, body) != DeserializationError::Ok) {
req->send(400, "application/json", "{\"error\":\"bad json\"}");
return;
}
bool reboot_after = doc["reboot"] | false;
JsonObject set = doc["set"];
WCLock lock(_mux);
// A DONE batch stays readable until the next POST claims the slot, so a
// client that lost the result response can re-poll instead of failing.
if (_batch_state == BATCH_PENDING) {
req->send(409, "application/json", "{\"error\":\"busy\"}");
return;
}
int count = 0;
for (JsonPair kv : set) {
const char* key = kv.key().c_str();
const char* val = kv.value().as<const char*>();
if (!val || !isAllowedSetKey(key)) {
char err[96];
snprintf(err, sizeof(err), "{\"error\":\"bad key\",\"key\":\"%.32s\"}", key);
req->send(400, "application/json", err);
return;
}
if (isSecretKey(key) && strcmp(val, SECRET_SENTINEL) == 0) continue; // unchanged
if (count >= MAX_BATCH) {
req->send(400, "application/json", "{\"error\":\"too many changes\"}");
return;
}
BatchEntry& e = _batch[count];
strncpy(e.key, key, sizeof(e.key) - 1);
e.key[sizeof(e.key) - 1] = 0;
// Build "set <key> <value>", stripping CR/LF so a value can't smuggle in
// a second command.
int pos = snprintf(e.cmd, sizeof(e.cmd), "set %s ", key);
for (const char* p = val; *p && pos < (int)sizeof(e.cmd) - 1; p++) {
if (*p == '\r' || *p == '\n') continue;
e.cmd[pos++] = *p;
}
e.cmd[pos] = 0;
count++;
}
if (count == 0 && !reboot_after) {
req->send(400, "application/json", "{\"error\":\"no changes\"}");
return;
}
_batch_count = count;
_batch_next = 0;
_batch_reboot = reboot_after;
_batch_reboot_armed = false;
_batch_state = BATCH_PENDING; // tick() picks it up on the loop task
uint32_t du = millis() + 60000;
if (du == 0) du = 1;
_diag_until = du;
Serial.printf("WC: config POST accepted, %d cmds, reboot=%d\n", count, (int)reboot_after);
char msg[64];
snprintf(msg, sizeof(msg), "{\"state\":\"pending\",\"count\":%d}", count);
req->send(202, "application/json", msg);
}
void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { Serial.println("WC: result read -> 503 (mode off)"); req->send(503); return; }
if (!checkAuth(req)) { Serial.println("WC: result read -> 401"); req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
// Entry print BEFORE the lock (racy state read is fine for diag): if this
// fires but no branch print follows, the handler is blocked on _mux.
Serial.printf("WC: result entry mode=%d state=%d\n", (int)_mode, (int)_batch_state);
WCLock lock(_mux);
if (_batch_state == BATCH_IDLE) {
Serial.println("WC: result read -> idle");
req->send(200, "application/json", "{\"state\":\"idle\"}");
return;
}
if (_batch_state == BATCH_PENDING) {
req->send(200, "application/json", "{\"state\":\"pending\"}");
return;
}
Serial.printf("WC: result read -> done (reboot=%d armed=%d)\n",
(int)_batch_reboot, (int)_batch_reboot_armed);
DynamicJsonDocument doc(6144);
doc["state"] = "done";
doc["reboot"] = _batch_reboot;
JsonArray results = doc.createNestedArray("results");
for (int i = 0; i < _batch_count; i++) {
JsonObject r = results.createNestedObject();
r["key"] = (const char*)_batch[i].key;
r["reply"] = (const char*)_batch[i].reply;
}
// State stays DONE (re-readable) until the next POST claims the slot.
if (_batch_reboot && !_batch_reboot_armed) {
// Confirmation delivered — reboot 3 s from now (replaces the 15 s
// drain-time fallback) so the UI can show its countdown first. Armed
// once; re-reads must not keep pushing the deadline out.
_batch_reboot_armed = true;
_reboot_at = millis() + 3000;
if (_reboot_at == 0) _reboot_at = 1;
}
AsyncResponseStream* res = req->beginResponseStream("application/json");
serializeJson(doc, *res);
req->send(res);
}
void WebConfigServer::handleStats(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
uint32_t until = millis() + 15000;
if (until == 0) until = 1;
_stats_wanted_until = until; // tick() refreshes the snapshot while polled
WCLock lock(_mux);
if (_stats_json[0] == 0) {
req->send(200, "application/json", "{\"state\":\"pending\"}");
return;
}
req->send(200, "application/json", _stats_json);
}
void WebConfigServer::handleScan(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
int n = WiFi.scanComplete();
if (req->hasParam("rescan") && n >= 0) {
WiFi.scanDelete();
n = WIFI_SCAN_FAILED;
}
if (n == WIFI_SCAN_FAILED) {
WiFi.scanNetworks(true);
req->send(200, "application/json", "{\"state\":\"scanning\"}");
return;
}
if (n < 0) { // WIFI_SCAN_RUNNING
req->send(200, "application/json", "{\"state\":\"scanning\"}");
return;
}
DynamicJsonDocument doc(3072);
doc["state"] = "done";
JsonArray nets = doc.createNestedArray("networks");
for (int i = 0; i < n && i < 20; i++) {
JsonObject net = nets.createNestedObject();
net["ssid"] = WiFi.SSID(i);
net["rssi"] = WiFi.RSSI(i);
net["enc"] = WiFi.encryptionType(i) != WIFI_AUTH_OPEN;
}
AsyncResponseStream* res = req->beginResponseStream("application/json");
serializeJson(doc, *res);
req->send(res);
}
void WebConfigServer::handlePresets(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
_last_activity = millis();
DynamicJsonDocument doc(3072);
JsonArray arr = doc.createNestedArray("presets");
for (int i = 0; i < MQTT_PRESET_COUNT; i++) {
const MQTTPresetDef& p = MQTT_PRESETS[i];
JsonObject o = arr.createNestedObject();
o["name"] = p.name;
// What the UI must collect for this preset to connect
if (p.topic_style == MQTT_TOPIC_MESHRANK) {
o["needs"] = "token";
} else if (mqttPresetNeedsSlotCredentials(&p)) {
o["needs"] = "userpass";
} else {
o["needs"] = "none";
}
}
AsyncResponseStream* res = req->beginResponseStream("application/json");
serializeJson(doc, *res);
req->send(res);
}
void WebConfigServer::handleReboot(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
_reboot_at = millis() + 1500;
if (_reboot_at == 0) _reboot_at = 1;
req->send(200, "application/json", "{\"ok\":true}");
}
void WebConfigServer::handlePortalExit(AsyncWebServerRequest* req) {
// Setup mode only: switch captive probes to native "success" replies so the
// OS sign-in sheet can be dismissed (iOS: "Done") without dropping the WiFi;
// the user then continues at http://<softAP IP>/ in their real browser,
// which survives the phone sleeping (the captive sheet does not).
if (_mode != MODE_SETUP) { req->send(404); return; }
_captive_release = true;
_last_activity = millis();
char body[64];
snprintf(body, sizeof(body), "{\"ok\":true,\"url\":\"http://%s/\"}",
WiFi.softAPIP().toString().c_str());
req->send(200, "application/json", body);
}
void WebConfigServer::handleNotFound(AsyncWebServerRequest* req) {
// Captive-portal probes (/generate_204, /hotspot-detect.html, /ncsi.txt,
// /connecttest.txt, ...) all land here; a redirect to the portal makes the
// phone pop its sign-in sheet.
if (_mode == MODE_SETUP && req->method() == HTTP_GET) {
if (_captive_release) {
// Answer each OS's connectivity check natively so the sheet reports
// success and can be closed. Deliberately does NOT bump _last_activity:
// background probes must not hold the portal open past the idle timeout.
const String& url = req->url();
if (url.indexOf("generate_204") >= 0 || url.indexOf("gen_204") >= 0) {
req->send(204); // Android
} else if (url.indexOf("hotspot-detect") >= 0 || url.indexOf("success") >= 0) {
req->send(200, "text/html", // Apple CNA
"<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>");
} else if (url.indexOf("ncsi.txt") >= 0) {
req->send(200, "text/plain", "Microsoft NCSI"); // Windows
} else if (url.indexOf("connecttest.txt") >= 0) {
req->send(200, "text/plain", "Microsoft Connect Test");
} else {
req->send(404);
}
return;
}
_last_activity = millis();
req->redirect(String("http://") + WiFi.softAPIP().toString() + "/");
return;
}
req->send(404);
}
#endif // ESP_PLATFORM && WITH_MQTT_BRIDGE
+169
View File
@@ -0,0 +1,169 @@
#pragma once
// Browser-based configuration portal for ESP32 MQTT observer builds.
//
// Two modes:
// - SETUP: open SoftAP + captive portal, raised automatically on first boot
// when no WiFi is configured (wifi_ssid empty), or manually via
// `start webconfig ap`. Save -> reboot; auto-stops after an idle timeout.
// - LAN: bound to the existing STA connection (owned by the MQTT bridge
// task), started via `start webconfig`, admin-password login required.
//
// Concurrency model: AsyncWebServer handlers run on the async_tcp task and
// must never touch the CLI, prefs persistence, or the radio. Config writes
// are marshaled into a single-slot command batch that tick() - called from
// MyMesh::loop() on the Arduino loop task - drains through the existing CLI
// `set` handlers. Prefs-struct reads and batch state are guarded by _mux.
//
// No persisted state: everything here is RAM-only, so prefs-file layouts
// (fleet-critical) are untouched.
#if defined(ESP_PLATFORM) && defined(WITH_MQTT_BRIDGE)
#define WITH_WEBCONFIG 1
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
class AsyncWebServer;
class AsyncWebServerRequest;
class DNSServer;
struct NodePrefs;
struct MQTTPrefs;
#ifndef WEBCONFIG_AP_IDLE_TIMEOUT_MS
#define WEBCONFIG_AP_IDLE_TIMEOUT_MS (10UL * 60UL * 1000UL)
#endif
#ifndef WEBCONFIG_SESSION_TTL_MS
#define WEBCONFIG_SESSION_TTL_MS (20UL * 60UL * 1000UL)
#endif
class WebConfigServer {
public:
enum Mode : uint8_t { MODE_OFF = 0, MODE_SETUP, MODE_LAN };
class Callbacks {
public:
// Run one CLI command. Called from tick() only (loop task); reply is 160 bytes.
virtual void execCommand(char* cmd, char* reply) = 0;
virtual void rebootNow() = 0;
// Bracket a config batch so bridge restarts triggered by individual
// `set` handlers can be coalesced into one.
virtual void onConfigBatchStart() {}
virtual void onConfigBatchEnd() {}
// Fill buf with the stats JSON snapshot. Called from tick() (loop task).
virtual void buildStatsJson(char* buf, size_t buf_size) = 0;
// Teardown finished (server + DNS freed, WiFi mode restored).
virtual void onWebConfigStopped() {}
};
WebConfigServer(NodePrefs* prefs, MQTTPrefs* obs, Callbacks* callbacks,
const uint8_t* pub_key, const char* fw_ver,
const char* role, const char* board_name);
~WebConfigServer();
// For the device display: true while a setup-mode portal is active.
// Fills the AP SSID and portal IP; either buffer may be NULL to just poll.
// Call from the loop task only (same task that changes the mode).
static bool getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t ip_len);
// For the device display: true once a config save completed and the node is
// about to reboot — ground truth for the user even if the browser lost its
// connection before the confirmation arrived. Loop task only.
static bool isRebootPending();
bool startSetupMode(char reply[]); // open SoftAP + DNS captive portal
bool startLanMode(char reply[]); // bind to existing STA connection
void requestStop(); // stop listening now, free after grace period
void tick(uint32_t now); // call every loop iteration
Mode mode() const { return _mode; }
bool isRunning() const { return _mode != MODE_OFF; }
bool isStopping() const { return _stopping; }
private:
static const int MAX_BATCH = 24;
static const size_t MAX_BODY = 4096;
enum BatchState : uint8_t { BATCH_IDLE = 0, BATCH_PENDING, BATCH_DONE };
struct BatchEntry {
char key[24]; // allowlisted `set` key (echoed back to the UI)
char cmd[160]; // full CLI command (may contain secrets - never echoed)
char reply[160];
};
NodePrefs* _prefs;
MQTTPrefs* _obs;
Callbacks* _cb;
const uint8_t* _pub_key;
const char* _fw_ver;
const char* _role;
const char* _board_name;
AsyncWebServer* _server = NULL;
DNSServer* _dns = NULL;
SemaphoreHandle_t _mux;
Mode _mode = MODE_OFF;
bool _stopping = false;
bool _was_setup_ap = false;
char _ap_ssid[33] = {0};
// Most-recently-created instance, for the display's getSetupInfo() poll.
static WebConfigServer* _active;
// Command batch: filled by async_tcp under _mux, drained by tick().
volatile BatchState _batch_state = BATCH_IDLE;
uint8_t _batch_count = 0;
uint8_t _batch_next = 0; // drain progress (one command per tick)
uint32_t _batch_last_cmd = 0;
bool _batch_reboot = false;
bool _batch_reboot_armed = false;
BatchEntry _batch[MAX_BATCH];
// LAN-mode session (single slot; new login evicts the old session)
char _session_token[33] = {0};
uint32_t _session_last_seen = 0;
uint8_t _login_fails = 0;
uint32_t _login_lock_until = 0;
// Once set (via /api/portal/exit), OS captive probes get native "success"
// replies so the phone's sign-in sheet can be dismissed without dropping the
// WiFi, letting the user continue in their real browser. async_tcp-task only.
volatile bool _captive_release = false;
// Save-path diagnostics: 1 Hz serial trace for 60 s after each config POST
// (AP station count, heap, batch state) to pinpoint client drops on hardware.
volatile uint32_t _diag_until = 0;
uint32_t _diag_last = 0;
volatile uint32_t _last_activity = 0;
uint32_t _reboot_at = 0; // 0 = none scheduled
uint32_t _delete_at = 0; // deferred teardown deadline
volatile uint32_t _stats_wanted_until = 0;
uint32_t _stats_built_at = 0;
char _stats_json[1024] = {0};
void createServer();
void registerRoutes();
void drainBatch(uint32_t now);
void finalizeTeardown();
bool checkAuth(AsyncWebServerRequest* req);
static void collectBody(AsyncWebServerRequest* req, uint8_t* data, size_t len,
size_t index, size_t total);
void handleRoot(AsyncWebServerRequest* req);
void handleStatus(AsyncWebServerRequest* req);
void handleLogin(AsyncWebServerRequest* req);
void handleLogout(AsyncWebServerRequest* req);
void handleConfigGet(AsyncWebServerRequest* req);
void handleConfigPost(AsyncWebServerRequest* req);
void handleConfigResult(AsyncWebServerRequest* req);
void handleStats(AsyncWebServerRequest* req);
void handleScan(AsyncWebServerRequest* req);
void handlePresets(AsyncWebServerRequest* req);
void handleReboot(AsyncWebServerRequest* req);
void handlePortalExit(AsyncWebServerRequest* req);
void handleNotFound(AsyncWebServerRequest* req);
};
#endif // ESP_PLATFORM && WITH_MQTT_BRIDGE