Share the saved admin password across ESP32 management services

Use the committed node credential for setup WiFi, WebConfig and browser OTA. Add persistent ESP32 WiFi Companion management credentials without changing preferences layouts, revoke stale browser sessions on rotation, retain scoped save receipts, and coordinate credential changes with flash uploads. Keep external-service credentials and trusted TCP protocol compatibility independent. Update live join QR codes, failure handling and documentation.

Validated 51 native cases, 158 related host and browser methods, and serial Full V4 Companion, Station G3 Repeater, ESP32-C6 Room Server and V4 Sensor firmware builds.
This commit is contained in:
mikecarper committed 2026-10-18 13:09:48 -07:00
1 parent a6a3cb5ebc
commit cd02e55824
58 files changed
+5749 -2439

No files matched your search

+6
View File
@@ -1,6 +1,9 @@
#include "Mesh.h"
#include <helpers/ManagementReport.h>
#include <SHA256.h>
#ifdef ESP_PLATFORM
#include <helpers/esp32/ManagementPassword.h>
#endif
#include "helpers/ota/OtaFormat.h" // request types used by TempRadio relay-pressure tracking
//#include <Arduino.h>
#if defined(ENABLE_OTA)
@@ -407,6 +410,9 @@ void Mesh::loop() {
if (_ota_forwarded[0].valid && !isAnyTempRadioActive()) resetOtaForwardCache();
Dispatcher::loop();
serviceLoopMaintenance();
#ifdef ESP_PLATFORM
esp32::managementPassword::service(millis());
#endif
#if defined(ENABLE_OTA) && defined(ESP32_PLATFORM) && \
(defined(WIFI_OTA_SEEDER) || defined(WIFI_SSID))
ota::WiFiOtaSeeder::loop();
+29
View File
@@ -56,6 +56,7 @@
#define BRIDGE_MAX_BAUD 115200
#endif
#ifdef ESP_PLATFORM
#include <helpers/esp32/ManagementPassword.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <esp_wifi.h>
@@ -3046,6 +3047,11 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
} else if (memcmp(command, "start webconfig", 15) == 0
&& (command[15] == 0 || command[15] == ' ')) {
if (_prefs->password[0]
&& !mesh::esp32::managementPassword::publish(_prefs->password)) {
strcpy(reply, "ERR: management password busy; retry");
return;
}
const bool force_ap = command[15] == ' ' && strcmp(&command[16], "ap") == 0;
if (command[15] == ' ' && !force_ap) {
strcpy(reply, "ERR: usage start webconfig [ap]");
@@ -3077,6 +3083,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
strcpy(reply, "ERR: usage start ota [ap]");
return;
}
#ifdef ESP_PLATFORM
if (!mesh::esp32::managementPassword::publish(_prefs->password)) {
strcpy(reply, "ERR: set an admin password first, or wait for the active OTA upload");
return;
}
#endif
#if defined(WITH_ESPNOW_BRIDGE)
const bool ota_was_running = _board->isOTAUpdateRunning();
// Keep an outstanding pause if an earlier resume failed. Only a
@@ -3250,6 +3262,17 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
}
} else if (memcmp(command, "password ", 9) == 0) {
// change admin password
#ifdef ESP_PLATFORM
if (!mesh::esp32::managementPassword::validPassword(&command[9])) {
strcpy(reply, "Error: password must be 1-15 bytes, no line breaks; use 8-15 ASCII for setup WiFi");
return;
}
mesh::esp32::managementPassword::ChangeGuard change;
if (!change.acquired()) {
strcpy(reply, "Error: OTA upload or password change busy; retry");
return;
}
#endif
char previous[sizeof(_prefs->password)];
memcpy(previous, _prefs->password, sizeof(previous));
StrHelper::strncpy(_prefs->password, &command[9], sizeof(_prefs->password));
@@ -3258,6 +3281,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
strcpy(reply, "Error: save unconfirmed; reboot may differ");
return;
}
#ifdef ESP_PLATFORM
if (!change.publish(_prefs->password)) {
strcpy(reply, "Error: password saved; management unavailable, reboot required");
return;
}
#endif
sprintf(reply, "password now: %s", _prefs->password); // echo back just to let admin know for sure!!
} else if (memcmp(command, "clear stats", 11) == 0) {
_callbacks->clearStats();
+95 -7
View File
@@ -7,6 +7,7 @@
#include "UsbLogging.h"
#include "FileRead.h"
#include "UserGpioPinPolicy.h"
#include <helpers/esp32/ManagementPassword.h>
namespace mesh {
namespace esp32_clock {
@@ -145,7 +146,13 @@ class LightweightOTAServer {
sendResponse(client, code, reason, "text/plain", error, strlen(error));
}
void receiveUpdate(WiFiClient& client, size_t total) {
void receiveUpdate(WiFiClient& client, size_t total, uint32_t auth_generation) {
mesh::esp32::managementPassword::UploadGuard upload(auth_generation);
if (!upload.acquired()) {
static const char busy[] = "password change or OTA upload in progress; retry";
sendResponse(client, 409, "Conflict", "text/plain", busy, sizeof(busy) - 1);
return;
}
if (total == 0) {
static const char empty[] = "empty firmware image";
sendResponse(client, 400, "Bad Request", "text/plain", empty, sizeof(empty) - 1);
@@ -208,17 +215,43 @@ class LightweightOTAServer {
bool post_update = strncmp(line, "POST /update ", 13) == 0;
size_t content_length = 0;
char authorization[96] = {};
bool duplicate_authorization = false, have_authorization = false;
do {
if (!readLine(client, line, sizeof(line))) return;
if (strncasecmp(line, "Content-Length:", 15) == 0) {
content_length = static_cast<size_t>(strtoul(line + 15, nullptr, 10));
} else if (strncasecmp(line, "Authorization:", 14) == 0) {
if (have_authorization) duplicate_authorization = true;
have_authorization = true;
const char* value = line + 14;
while (*value == ' ' || *value == '\t') ++value;
if (strlen(value) >= sizeof(authorization)) duplicate_authorization = true;
else memcpy(authorization, value, strlen(value) + 1);
}
} while (line[0]);
if (!running) return;
uint32_t generation = 0;
const auto auth = mesh::esp32::managementPassword::authenticateBasic(
duplicate_authorization ? "invalid" : authorization, millis(), &generation);
memset(authorization, 0, sizeof(authorization));
if (auth != mesh::esp32::managementPassword::AuthResult::Authorized) {
if (auth == mesh::esp32::managementPassword::AuthResult::Unavailable) {
static const char unavailable[] = "saved admin password unavailable";
sendResponse(client, 503, "Service Unavailable", "text/plain", unavailable, sizeof(unavailable) - 1);
} else if (auth == mesh::esp32::managementPassword::AuthResult::Throttled) {
static const char throttled[] = "too many failed logins; wait 30 seconds";
sendResponse(client, 429, "Too Many Requests", "text/plain", throttled, sizeof(throttled) - 1);
} else {
client.print("HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"MeshCore admin\", charset=\"UTF-8\"\r\n"
"Content-Length: 0\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n");
}
return;
}
if (get_page) sendPage(client);
else if (get_log) sendLog(client);
else if (post_update) receiveUpdate(client, content_length);
else if (post_update) receiveUpdate(client, content_length, generation);
else {
static const char missing[] = "not found";
sendResponse(client, 404, "Not Found", "text/plain", missing, sizeof(missing) - 1);
@@ -276,6 +309,11 @@ static LightweightOTAServer lightweight_ota_server;
bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
(void)id;
mesh::esp32::managementPassword::Snapshot credential;
if (!mesh::esp32::managementPassword::snapshot(credential) || !credential.password[0]) {
strcpy(reply, "ERR: saved admin password required for WiFi OTA");
return false;
}
if (ota_server != nullptr && !lightweight_ota_server.isRunning()) {
strcpy(reply, "ERR: OTA stopping; retry stop ota first");
return false;
@@ -306,7 +344,7 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
return false;
}
ota_started_radio = ota_started_radio || WiFi.getMode() == WIFI_OFF;
ota_started_ap = mesh::wifi::startOpenAccessPoint("MeshCore-OTA", ota_started_ap);
ota_started_ap = mesh::wifi::startManagementAccessPoint("MeshCore-OTA", ota_started_ap);
if (!ota_started_ap) {
inhibit_sleep = ota_server != nullptr;
if (!inhibit_sleep) {
@@ -332,7 +370,12 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
ota_server = &lightweight_ota_server;
}
const char* network = use_ap ? "Join WiFi MeshCore-OTA" : "Use same WiFi/LAN";
if (ota_started_ap) mesh::esp32::managementPassword::watchOtaAccessPoint(
this, rotateOTAAccessPoint, credential.generation);
const char* network = !use_ap ? "Use same WiFi/LAN"
: mesh::esp32::managementPassword::apPasswordEligible(credential.password)
? "Join WiFi MeshCore-OTA (admin password)"
: "Join WiFi MeshCore-OTA (OPEN compatibility; HTTP admin login)";
if (LIGHTWEIGHT_OTA_PORT == 80) {
snprintf(reply, 160, "Started: http://%s/update - WiFi on; %s", ip.toString().c_str(), network);
} else {
@@ -344,6 +387,10 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
}
bool ESP32Board::stopOTAUpdate(char reply[]) {
if (mesh::esp32::managementPassword::uploadActive()) {
strcpy(reply, "ERR: OTA upload active; retry stop ota after it finishes");
return false;
}
if (ota_server == nullptr) {
if (ota_started_ap) WiFi.softAPdisconnect(true);
ota_started_ap = false;
@@ -359,6 +406,7 @@ bool ESP32Board::stopOTAUpdate(char reply[]) {
return false;
}
ota_server = nullptr;
mesh::esp32::managementPassword::forgetOtaAccessPoint(this);
if (ota_started_ap) WiFi.softAPdisconnect(true);
ota_started_ap = false;
mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio);
@@ -385,6 +433,11 @@ bool ESP32Board::stopOTAUpdate(char reply[]) {
static AsyncWebServer* async_ota_host = nullptr;
bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
mesh::esp32::managementPassword::Snapshot credential;
if (!mesh::esp32::managementPassword::snapshot(credential) || !credential.password[0]) {
strcpy(reply, "ERR: saved admin password required for WiFi OTA");
return false;
}
if (!mesh::wireless::control().allowWiFiForOTA()) {
strcpy(reply, "ERR: wireless change pending; retry start ota");
return false;
@@ -409,7 +462,7 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
return false;
}
ota_started_radio = ota_started_radio || WiFi.getMode() == WIFI_OFF;
ota_started_ap = mesh::wifi::startOpenAccessPoint("MeshCore-OTA", ota_started_ap);
ota_started_ap = mesh::wifi::startManagementAccessPoint("MeshCore-OTA", ota_started_ap);
if (!ota_started_ap) {
inhibit_sleep = ota_server != nullptr;
if (!inhibit_sleep) {
@@ -422,8 +475,11 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
ip = WiFi.softAPIP();
}
snprintf(reply, 160, "Started: http://%s/update - WiFi on; %s", ip.toString().c_str(),
use_ap ? "Join WiFi MeshCore-OTA" : "Use same WiFi/LAN");
const char* network = !use_ap ? "Use same WiFi/LAN"
: mesh::esp32::managementPassword::apPasswordEligible(credential.password)
? "Join WiFi MeshCore-OTA (admin password)"
: "Join WiFi MeshCore-OTA (OPEN compatibility; HTTP admin login)";
snprintf(reply, 160, "Started: http://%s/update - WiFi on; %s", ip.toString().c_str(), network);
MESH_DEBUG_PRINTLN("startOTAUpdate: %s", reply);
// Select the requested network even when the HTTP listener already exists.
@@ -434,6 +490,8 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
strcpy(reply, "ERR: OTA server failed; retry start ota");
return false;
}
if (ota_started_ap) mesh::esp32::managementPassword::watchOtaAccessPoint(
this, rotateOTAAccessPoint, credential.generation);
return true;
}
@@ -454,11 +512,14 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
return false;
}
async_ota_host->on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
if (!AsyncElegantOTA.authenticateRequest(request)) return;
request->send(200, "text/html", home_buf);
});
async_ota_host->on("/log", HTTP_GET, [](AsyncWebServerRequest *request) {
if (!AsyncElegantOTA.authenticateRequest(request)) return;
request->send(SPIFFS, "/packet_log", "text/plain");
});
AsyncElegantOTA.useManagementPassword();
AsyncElegantOTA.begin(async_ota_host);
}
ota_server = async_ota_host;
@@ -479,10 +540,16 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
return false;
}
if (ota_started_ap) mesh::esp32::managementPassword::watchOtaAccessPoint(
this, rotateOTAAccessPoint, credential.generation);
return true;
}
bool ESP32Board::stopOTAUpdate(char reply[]) {
if (mesh::esp32::managementPassword::uploadActive()) {
strcpy(reply, "ERR: OTA upload active; retry stop ota after it finishes");
return false;
}
if (ota_server == nullptr) {
if (ota_started_ap) WiFi.softAPdisconnect(true);
ota_started_ap = false;
@@ -499,6 +566,7 @@ bool ESP32Board::stopOTAUpdate(char reply[]) {
}
ota_server->end();
ota_server = nullptr;
mesh::esp32::managementPassword::forgetOtaAccessPoint(this);
if (ota_started_ap) WiFi.softAPdisconnect(true);
ota_started_ap = false;
mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio);
@@ -521,6 +589,26 @@ bool ESP32Board::stopOTAUpdate(char reply[]) {
}
#endif
#if (defined(LIGHTWEIGHT_WIFI_OTA) && (defined(ADMIN_PASSWORD) || defined(COMPANION_RADIO_FULL))) \
|| (defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA))
bool ESP32Board::rotateOTAAccessPoint(void* context) {
auto* owner = static_cast<ESP32Board*>(context);
if (!owner->ota_server || !owner->ota_started_ap) {
mesh::esp32::managementPassword::forgetOtaAccessPoint(owner);
return true;
}
if (!mesh::wifi::startManagementAccessPoint("MeshCore-OTA", false)) {
// A failed rekey must not leave an AP accepting the previous secret.
WiFi.softAPdisconnect(true);
owner->ota_started_ap = false;
char reply[160];
owner->stopOTAUpdate(reply);
mesh::esp32::managementPassword::forgetOtaAccessPoint(owner);
}
return true;
}
#endif
// ---------------------------------------------------------------------------
// Manifest-driven pull OTA (observer / MQTT-bridge builds only)
//
+1
View File
@@ -314,6 +314,7 @@ public:
}
bool startOTAUpdate(const char* id, char reply[], bool force_ap = false) override;
static bool rotateOTAAccessPoint(void* owner);
bool stopOTAUpdate(char reply[]) override;
bool isOTAUpdateRunning() const override { return ota_server != nullptr; }
bool otaFromManifest(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) override;
+1
View File
@@ -46,6 +46,7 @@ static const uint32_t kRebootConfirmMs = 3000; // .cpp:784 first result-read
static const uint32_t kSetupWiFiConnectTimeoutMs = 20000;
static const uint32_t kSetupHandoffRebootConfirmMs = 1000;
static const uint32_t kStopWarnMs = 10000; // .h:95 STOP_WARN_MS
static const uint32_t kReauthReceiptMs = 120000;
static const uint32_t kFullSetupApWindowMs = 30UL * 60UL * 1000UL;
// The batch lifecycle. A fresh POST moves Idle->Pending; the drainer moves
+69 -1
View File
@@ -16,6 +16,7 @@
#include <helpers/esp32/WiFiAccessPointPolicy.h>
#include <helpers/WirelessControl.h>
#include <helpers/esp32/WiFiStationPolicy.h>
#include <helpers/esp32/ManagementPassword.h>
namespace {
@@ -41,6 +42,9 @@ struct PortalImpl {
uint32_t next_recovery_at = 0;
uint32_t recovery_deadline = 0;
bool recovery_connecting = false;
uint32_t ap_password_generation = 0;
uint32_t rekey_generation = 0;
uint32_t rekey_at = 0;
};
static bool readLine(WiFiClient& client, char* line, size_t capacity) {
@@ -223,14 +227,53 @@ static void handleClient(PortalImpl* impl, WiFiClient& client) {
bool post_save = strncmp(line, "POST /save ", 11) == 0;
bool get_request = strncmp(line, "GET ", 4) == 0;
size_t content_length = 0;
char authorization[96] = {};
bool has_authorization = false;
do {
if (!readLine(client, line, sizeof(line))) return;
if (strncasecmp(line, "Content-Length:", 15) == 0) {
content_length = static_cast<size_t>(strtoul(line + 15, nullptr, 10));
} else if (strncasecmp(line, "Authorization:", 14) == 0) {
const char* value = line + 14;
while (*value == ' ' || *value == '\t') ++value;
if (has_authorization || strlen(value) >= sizeof(authorization)) {
strcpy(authorization, "invalid");
} else {
memcpy(authorization, value, strlen(value) + 1);
}
has_authorization = true;
}
} while (line[0]);
using namespace mesh::esp32::managementPassword;
uint32_t authenticated_generation = 0;
const AuthResult auth = authenticateBasic(authorization, millis(), &authenticated_generation);
memset(authorization, 0, sizeof(authorization));
if (auth != AuthResult::Authorized) {
if (auth == AuthResult::Throttled) {
sendResponse(client, 429, "Too Many Requests", "text/plain", "Wait 30 seconds before signing in again.");
} else if (auth == AuthResult::Unavailable) {
sendResponse(client, 503, "Service Unavailable", "text/plain", "Set the node admin password on USB, then restart setup WiFi.");
} else {
static const char message[] = "Sign in as admin using the node admin password.";
client.printf("HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"MeshCore\", charset=\"UTF-8\"\r\n"
"Content-Type: text/plain\r\nContent-Length: %u\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n%s",
static_cast<unsigned>(sizeof(message) - 1), message);
}
return;
}
if (post_save) {
// Keep the accepted request bound to the authenticated credential while
// association and persistence run. A concurrent password change retries
// after this operation rather than authorizing a stale request midway.
ChangeGuard request;
Snapshot current;
if (!request.acquired() || !snapshot(current)
|| current.generation != authenticated_generation) {
sendResponse(client, 409, "Conflict", "text/plain", "Settings changed or are busy; sign in again and retry.");
return;
}
if (content_length == 0 || content_length >= 256) {
sendResponse(client, 400, "Bad Request", "text/plain", "Invalid form data.");
return;
@@ -260,6 +303,25 @@ static void portalTask(void* arg) {
PortalImpl* impl = static_cast<PortalImpl*>(arg);
while (impl->active && *impl->active) {
impl->dns.processNextRequest();
{
using namespace mesh::esp32::managementPassword;
Snapshot current;
if (snapshot(current) && current.generation != impl->ap_password_generation) {
if (impl->rekey_generation != current.generation) {
impl->rekey_generation = current.generation;
impl->rekey_at = millis();
} else if (uint32_t(millis() - impl->rekey_at) >= 750U) {
ChangeGuard rekey;
if (rekey.acquired()) {
if (!snapshot(current)) break;
if (!mesh::wifi::startManagementAccessPoint(impl->ap_name, false)) break;
impl->ap_password_generation = current.generation;
impl->rekey_generation = 0;
mesh::usbLoggingPort().println("Setup WiFi updated; reconnect using the admin password");
}
}
}
}
WiFiClient client = impl->server.available();
if (client) {
client.setTimeout(5000);
@@ -324,6 +386,10 @@ bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, voi
_impl = impl;
}
if (impl->task != nullptr) return false;
mesh::esp32::managementPassword::ChangeGuard start;
if (!start.acquired()) return false;
mesh::esp32::managementPassword::Snapshot admin;
if (!mesh::esp32::managementPassword::snapshot(admin) || !admin.password[0]) return false;
impl->save_callback = save_callback;
impl->callback_context = context;
@@ -337,12 +403,14 @@ bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, voi
impl->ap_name[sizeof(impl->ap_name) - 1] = 0;
impl->started_radio = WiFi.getMode() == WIFI_OFF;
if (!mesh::wifi::startOpenAccessPoint(impl->ap_name, false)) {
if (!mesh::wifi::startManagementAccessPoint(impl->ap_name, false)) {
WiFi.softAPdisconnect(true);
mesh::wifi::stopTemporaryAccessPointRadio(impl->started_radio);
impl->started_radio = false;
return false;
}
impl->ap_password_generation = admin.generation;
impl->rekey_generation = 0;
if (!impl->dns.start(53, "*", SETUP_IP)) {
WiFi.softAPdisconnect(true);
mesh::wifi::stopTemporaryAccessPointRadio(impl->started_radio);
+3 -2
View File
@@ -11,8 +11,9 @@ public:
WiFiSetupPortal();
// Starts an open setup AP and captive HTTP form. The AP remains available
// after a failed association and shuts down shortly after a successful one.
// Starts a setup AP and captive HTTP form using the saved admin password.
// Legacy short passwords retain open WiFi plus authenticated HTTP. The AP
// stays available after a failed association and closes after success.
bool begin(const char* ap_name, SaveCallback save_callback, void* context);
void stop();
// Retry interval is the steady-state cadence. initial_delay_ms defaults to
@@ -0,0 +1,78 @@
#pragma once
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
#include <Preferences.h>
#include <stddef.h>
#include <string.h>
namespace mesh { namespace esp32 {
// ESP32 Companion management credentials are separate from the fixed binary
// Companion preferences image. Existing identities, contacts and wire offsets
// do not change when management authentication is added.
namespace companionAdminPassword {
static constexpr size_t CAPACITY = 16;
inline bool valid(const char* value) {
if (!value) return false;
const size_t length = strnlen(value, CAPACITY);
if (length == 0 || length >= CAPACITY) return false;
for (size_t i = 0; i < length; ++i) {
if (value[i] == '\r' || value[i] == '\n') return false;
}
return true;
}
inline bool load(char (&out)[CAPACITY], const char* factory_password) {
memset(out, 0, sizeof(out));
Preferences prefs;
if (!prefs.begin("mesh-admin", false)) return false;
const bool has_password = prefs.isKey("password");
const bool has_pending = prefs.isKey("pending");
bool ok = !has_pending || (prefs.getType("pending") == PT_U8
&& prefs.getUChar("pending", 1) == 0);
if (ok && !has_password && !has_pending) {
ok = valid(factory_password);
if (ok) memcpy(out, factory_password, strlen(factory_password) + 1);
} else if (ok && has_password && prefs.getType("password") == PT_STR) {
const size_t count = prefs.getString("password", out, sizeof(out));
ok = count > 1 && count <= sizeof(out) && out[count - 1] == 0
&& count == strnlen(out, sizeof(out)) + 1 && valid(out);
} else {
ok = false;
}
prefs.end();
if (!ok) memset(out, 0, sizeof(out));
return ok;
}
inline bool save(const char* password) {
if (!valid(password)) return false;
Preferences prefs;
if (!prefs.begin("mesh-admin", false)) return false;
// Each NVS operation commits. An interrupted multi-key change must not
// reopen the web interface using a guessed factory or half-saved password.
bool ok = prefs.putUChar("pending", 1) == 1
&& prefs.getUChar("pending", 0) == 1;
char verified[CAPACITY] = {};
if (ok) {
const size_t length = strlen(password);
ok = prefs.putString("password", password) == length
&& prefs.getType("password") == PT_STR
&& prefs.getString("password", verified, sizeof(verified)) == length + 1
&& memcmp(verified, password, length + 1) == 0;
}
if (ok) {
ok = prefs.putUChar("pending", 0) == 1
&& prefs.getUChar("pending", 1) == 0;
}
prefs.end();
memset(verified, 0, sizeof(verified));
return ok;
}
} // namespace companionAdminPassword
} } // namespace mesh::esp32
#endif
+261
View File
@@ -0,0 +1,261 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#if defined(MESH_MANAGEMENT_PASSWORD_HOST_TEST)
#include <mutex>
#elif defined(ESP_PLATFORM) || defined(ESP32_PLATFORM)
#include <freertos/FreeRTOS.h>
#endif
namespace mesh {
namespace esp32 {
namespace managementPassword {
// The wire/prefs field is sixteen bytes, including its terminator. Do not
// silently truncate a new secret or change an existing node's credential.
inline bool validPassword(const char* value) {
if (!value) return false;
size_t n = 0;
while (n < 16 && value[n]) {
if (value[n] == '\r' || value[n] == '\n') return false;
++n;
}
return n != 0 && n < 16;
}
inline bool apPasswordEligible(const char* value) {
if (!validPassword(value)) return false;
size_t n = 0;
for (; value[n]; ++n) {
const unsigned char c = static_cast<unsigned char>(value[n]);
if (c < 0x20 || c > 0x7e) return false;
}
return n >= 8;
}
// Exact, bounded HTTP Basic credentials: fixed username admin.
inline bool basicAuthorizationMatches(const char* authorization, const char* password) {
if (!validPassword(password)) return false;
char input[22] = "admin:";
memcpy(input + 6, password, strlen(password));
static const char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
char expected[35] = "Basic ";
size_t out = 6;
const size_t length = 6 + strlen(password);
for (size_t i = 0; i < length; i += 3) {
const uint32_t bits = (uint32_t(uint8_t(input[i])) << 16)
| (i + 1 < length ? uint32_t(uint8_t(input[i + 1])) << 8 : 0)
| (i + 2 < length ? uint8_t(input[i + 2]) : 0);
expected[out++] = alphabet[bits >> 18]; expected[out++] = alphabet[(bits >> 12) & 63];
expected[out++] = i + 1 < length ? alphabet[(bits >> 6) & 63] : '=';
expected[out++] = i + 2 < length ? alphabet[bits & 63] : '=';
}
expected[out] = 0;
size_t supplied = 0;
if (authorization) while (supplied < sizeof(expected) && authorization[supplied]) ++supplied;
uint8_t difference = supplied == out ? 0 : 1;
for (size_t i = 0; i < out; ++i) {
unsigned char actual = i < supplied ? static_cast<unsigned char>(authorization[i]) : 0;
// HTTP auth scheme names are case-insensitive; the Base64 value is not.
if (i < 5 && actual >= 'a' && actual <= 'z') actual -= 'a' - 'A';
unsigned char wanted = static_cast<unsigned char>(expected[i]);
if (i < 5 && wanted >= 'a' && wanted <= 'z') wanted -= 'a' - 'A';
difference |= actual ^ wanted;
}
memset(input, 0, sizeof(input)); memset(expected, 0, sizeof(expected));
return difference == 0;
}
#if defined(ESP_PLATFORM) || defined(ESP32_PLATFORM) || defined(MESH_MANAGEMENT_PASSWORD_HOST_TEST)
struct Snapshot {
char password[16] = {};
uint32_t generation = 0;
};
namespace detail {
struct State {
#if defined(MESH_MANAGEMENT_PASSWORD_HOST_TEST)
std::mutex mutex;
#else
portMUX_TYPE mutex = portMUX_INITIALIZER_UNLOCKED;
#endif
Snapshot current;
bool changing = false, uploading = false, rotating = false;
uint8_t failures = 0;
uint32_t failure_window = 0;
void* ap_owner = nullptr;
bool (*rotate_ap)(void*) = nullptr;
uint32_t ap_generation = 0, pending_generation = 0, pending_at = 0;
};
// An inline function's local static is shared across translation units. The
// mirror is published only by the loop owner after a confirmed persistent save;
// HTTP tasks never read a mutable prefs struct or a factory-password macro.
inline State& state() { static State value; return value; }
class Lock {
State& value;
public:
explicit Lock(State& s) : value(s) {
#if defined(MESH_MANAGEMENT_PASSWORD_HOST_TEST)
value.mutex.lock();
#else
portENTER_CRITICAL(&value.mutex);
#endif
}
~Lock() {
#if defined(MESH_MANAGEMENT_PASSWORD_HOST_TEST)
value.mutex.unlock();
#else
portEXIT_CRITICAL(&value.mutex);
#endif
}
};
inline void assign(State& s, const char* password) {
if (s.current.generation && strcmp(s.current.password, password) == 0) return;
memset(s.current.password, 0, sizeof(s.current.password));
memcpy(s.current.password, password, strlen(password));
if (++s.current.generation == 0) ++s.current.generation;
s.failures = 0;
}
} // namespace detail
inline bool snapshot(Snapshot& out) {
detail::State& s = detail::state();
detail::Lock lock(s);
out = s.current;
return out.generation != 0;
}
inline bool available() { Snapshot value; return snapshot(value) && value.password[0] != 0; }
// Boot/load only: retain an explicitly empty legacy credential for services
// whose historical policy is passwordless. OTA still requires available().
// Never replace an already-published value with a later mutable prefs snapshot.
inline bool initialize(const char* saved_password) {
if (!saved_password || (saved_password[0] && !validPassword(saved_password))) return false;
detail::State& s = detail::state(); detail::Lock lock(s);
if (s.current.generation) return strcmp(s.current.password, saved_password) == 0;
if (s.changing || s.uploading || s.rotating) return false;
detail::assign(s, saved_password);
return true;
}
inline bool publish(const char* saved_password) {
if (!validPassword(saved_password)) return false;
detail::State& s = detail::state();
detail::Lock lock(s);
if (strcmp(s.current.password, saved_password) == 0) return true;
if (s.changing || s.uploading || s.rotating) return false;
detail::assign(s, saved_password);
return true;
}
// Reserve before changing RAM or saving, so a new upload cannot slip between
// an upload-busy check and publication. A failed save simply releases the guard.
class ChangeGuard {
bool held = false;
public:
ChangeGuard() {
detail::State& s = detail::state(); detail::Lock lock(s);
if (!s.changing && !s.uploading && !s.rotating) held = s.changing = true;
}
ChangeGuard(const ChangeGuard&) = delete;
ChangeGuard& operator=(const ChangeGuard&) = delete;
bool acquired() const { return held; }
bool publish(const char* saved_password) {
if (!held || !validPassword(saved_password)) return false;
detail::State& s = detail::state(); detail::Lock lock(s);
detail::assign(s, saved_password);
return true;
}
~ChangeGuard() {
if (held) { detail::State& s = detail::state(); detail::Lock lock(s); s.changing = false; }
}
};
inline bool beginUpload(uint32_t authenticated_generation) {
detail::State& s = detail::state(); detail::Lock lock(s);
if (!authenticated_generation || authenticated_generation != s.current.generation
|| s.changing || s.uploading || s.rotating) return false;
s.uploading = true;
return true;
}
inline void endUpload() {
detail::State& s = detail::state(); detail::Lock lock(s); s.uploading = false;
}
inline bool uploadActive() {
detail::State& s = detail::state(); detail::Lock lock(s); return s.uploading;
}
class UploadGuard {
bool held;
public:
explicit UploadGuard(uint32_t generation) : held(beginUpload(generation)) {}
UploadGuard(const UploadGuard&) = delete;
UploadGuard& operator=(const UploadGuard&) = delete;
bool acquired() const { return held; }
~UploadGuard() { if (held) endUpload(); }
};
enum class AuthResult : uint8_t { Authorized, Unauthorized, Unavailable, Throttled };
// Compare the bounded Basic value rather than storing decoded credentials.
// The browser's normal HTTP Basic prompt/cache also covers same-origin XHR.
inline AuthResult authenticateBasic(const char* authorization, uint32_t now,
uint32_t* generation = nullptr) {
Snapshot current;
if (!snapshot(current) || !current.password[0]) return AuthResult::Unavailable;
{
detail::State& s = detail::state(); detail::Lock lock(s);
if (uint32_t(now - s.failure_window) >= 30000U) { s.failures = 0; s.failure_window = now; }
if (s.failures >= 5) return AuthResult::Throttled;
}
const bool matches = basicAuthorizationMatches(authorization, current.password);
detail::State& s = detail::state(); detail::Lock lock(s);
if (s.current.generation != current.generation) return AuthResult::Unauthorized;
if (!matches) {
// A browser's first unauthenticated request triggers its login prompt and
// is not a guess. Count only a supplied, rejected Authorization header.
if (authorization && authorization[0]) {
if (!s.failures) s.failure_window = now;
if (s.failures < 5) ++s.failures;
}
return AuthResult::Unauthorized;
}
s.failures = 0;
if (generation) *generation = current.generation;
return AuthResult::Authorized;
}
inline void watchOtaAccessPoint(void* owner, bool (*rotate)(void*), uint32_t generation) {
detail::State& s = detail::state(); detail::Lock lock(s);
s.ap_owner = owner; s.rotate_ap = rotate; s.ap_generation = generation; s.pending_generation = 0;
}
inline void forgetOtaAccessPoint(void* owner) {
detail::State& s = detail::state(); detail::Lock lock(s);
if (s.ap_owner == owner) { s.ap_owner = nullptr; s.rotate_ap = nullptr; }
}
// Called on the mesh loop. Rekey only this OTA owner's AP after the password
// command's reply has had time to leave; do not race an accepted flash upload.
inline void service(uint32_t now) {
void* owner; bool (*rotate)(void*); uint32_t generation;
{
detail::State& s = detail::state(); detail::Lock lock(s);
if (!s.ap_owner || !s.rotate_ap || s.ap_generation == s.current.generation) return;
if (s.pending_generation != s.current.generation) {
s.pending_generation = s.current.generation; s.pending_at = now; return;
}
if (uint32_t(now - s.pending_at) < 750U || s.changing || s.uploading || s.rotating) return;
s.rotating = true; owner = s.ap_owner; rotate = s.rotate_ap; generation = s.current.generation;
}
const bool done = rotate(owner);
{
detail::State& s = detail::state(); detail::Lock lock(s);
s.rotating = false;
if (done && s.ap_owner == owner && s.current.generation == generation) s.ap_generation = generation;
}
}
#endif
} // namespace managementPassword
} // namespace esp32
} // namespace mesh
File diff suppressed because it is too large. Load diff
+331 -63
View File
@@ -45,6 +45,7 @@ static_assert(sizeof(WEBCONFIG_AP_PREFIX) <= 28,
#include "helpers/esp32/WiFiRadioPolicy.h"
#include "helpers/esp32/WiFiAccessPointPolicy.h"
#include "helpers/esp32/WiFiStationPolicy.h"
#include "helpers/esp32/ManagementPassword.h"
// ESPAsyncWebServer closes every ordinary response, so splitting the UI into
// many HTTP requests exhausts a lossy link's TCP/WiFi resources. Its ordinary
@@ -805,23 +806,22 @@ static bool setupAccessPointStarted() {
#endif
}
static bool setupAccessPointReady(const char* ssid, int channel) {
static bool setupAccessPointReady(const char* ssid, int channel, const char* password) {
wifi_mode_t mode = WIFI_MODE_NULL;
wifi_config_t config = {};
uint8_t protocol = 0;
const size_t ssid_len = strlen(ssid);
#ifdef WEBCONFIG_AP_PASSWORD
const wifi_auth_mode_t auth = WEBCONFIG_AP_PASSWORD[0]
const wifi_auth_mode_t auth = password && password[0]
? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
#else
const wifi_auth_mode_t auth = WIFI_AUTH_OPEN;
#endif
return setupAccessPointStarted()
&& esp_wifi_get_mode(&mode) == ESP_OK && (mode & WIFI_MODE_AP)
&& esp_wifi_get_config(WIFI_IF_AP, &config) == ESP_OK
&& config.ap.ssid_len == ssid_len
&& memcmp(config.ap.ssid, ssid, ssid_len) == 0
&& config.ap.ssid_hidden == 0 && config.ap.authmode == auth
&& (!password || !password[0]
|| strncmp(reinterpret_cast<const char*>(config.ap.password), password,
sizeof(config.ap.password)) == 0)
&& config.ap.channel == channel
&& esp_wifi_get_protocol(WIFI_IF_AP, &protocol) == ESP_OK
&& protocol == mesh::wifi::kAccessPointProtocolMask
@@ -856,6 +856,14 @@ bool WebConfigServer::startSetupMode(char reply[]) {
strcpy(reply, "Err: webconfig busy");
return false;
}
initializeManagementPassword();
mesh::esp32::managementPassword::Snapshot credential;
if (!mesh::esp32::managementPassword::snapshot(credential)) {
strcpy(reply, "Err: administrator credentials unavailable");
return false;
}
const char* ap_password = mesh::esp32::managementPassword::apPasswordEligible(
credential.password) ? credential.password : nullptr;
// Publish the foreground owner before changing interfaces. The background
// worker must not power down the station underneath this AP startup.
requestNetworkAccess();
@@ -896,11 +904,7 @@ bool WebConfigServer::startSetupMode(char reply[]) {
}
const bool configured = scan_prepared && station_ok
&& WiFi.softAPConfig(ap_ip, ap_ip, ap_mask);
#ifdef WEBCONFIG_AP_PASSWORD
ap_ok = configured && WiFi.softAP(_ap_ssid, WEBCONFIG_AP_PASSWORD, ap_channel);
#else
ap_ok = configured && WiFi.softAP(_ap_ssid, nullptr, ap_channel);
#endif
ap_ok = configured && WiFi.softAP(_ap_ssid, ap_password, ap_channel);
if (ap_ok) {
// ESP-NOW can leave the persistent AP protocol mask with the proprietary
// LR bit set. The driver then reports a healthy SoftAP, but ordinary
@@ -912,7 +916,7 @@ bool WebConfigServer::startSetupMode(char reply[]) {
if (ap_protocol_result == ESP_OK) {
const uint32_t start_started = millis();
do {
ap_ok = setupAccessPointReady(_ap_ssid, ap_channel);
ap_ok = setupAccessPointReady(_ap_ssid, ap_channel, ap_password);
if (!ap_ok) delay(20);
} while (!ap_ok && millis() - start_started < 300);
}
@@ -941,18 +945,22 @@ bool WebConfigServer::startSetupMode(char reply[]) {
_dns->start(53, "*", ip); // captive portal: every name resolves to us
_mode = MODE_SETUP;
if (!promote_lan) createServer();
_was_setup_ap = true;
NodeSnapshot node = {};
_cb->getNodeSnapshot(node);
_initial_setup = _wifi_ssid[0] == 0 && node.admin_password[0] != 0;
_initial_setup = _wifi_ssid[0] == 0 && credential.password[0] != 0;
_setup_provisioning_privilege = _initial_setup && ap_password != nullptr;
_ap_auth_generation = credential.generation;
_ap_rekey_generation = _ap_rekey_after = _ap_rekey_deadline = 0;
_setup_started_at = millis();
_last_activity = _setup_started_at;
if (!promote_lan) createServer();
refreshNetworkPowerHold(_setup_started_at);
// Keep the pre-AP scan results for the picker. Only an explicit browser
// rescan needs to leave the AP's home channel after clients have joined.
sprintf(reply, "WebConfig AP started: join '%s' then open http://%s/", _ap_ssid, ip.toString().c_str());
snprintf(reply, 160, "WebConfig AP: join '%s', http://%s/ (%s)", _ap_ssid,
ip.toString().c_str(), ap_password ? "WiFi: admin password"
: credential.password[0] ? "open WiFi; admin login required"
: "open WiFi; no admin password");
return true;
}
@@ -965,6 +973,7 @@ bool WebConfigServer::startLanMode(char reply[]) {
strcpy(reply, "Err: WiFi not connected");
return false;
}
initializeManagementPassword();
_mode = MODE_LAN;
_setup_started_at = 0;
_wifi_reconnect_tracker.noteConnected();
@@ -1121,6 +1130,8 @@ void WebConfigServer::finalizeTeardown() {
_was_setup_ap = false;
_keep_wifi_on_stop = false;
_initial_setup = false;
_setup_provisioning_privilege = false;
_ap_auth_generation = _ap_rekey_generation = _ap_rekey_after = _ap_rekey_deadline = 0;
_setup_started_at = 0;
_stopping = false;
_stop_warn_at = 0;
@@ -1138,7 +1149,14 @@ void WebConfigServer::finalizeTeardown() {
_setup_wifi_handoff_ip[0] = 0;
_batch_kind = BATCH_CONFIG;
_admin_pwd_set = false;
_session_token[0] = 0;
{
WCLock lock(_mux);
clearSessionLocked();
memset(_batch_owner_token, 0, sizeof(_batch_owner_token));
_batch_reauth_required = false;
_batch_reauth_delivered = false;
_batch_reauth_until = 0;
}
_stats_json[0] = 0;
_board_cmds = 0;
_board_cmds_probed = false;
@@ -1161,8 +1179,48 @@ void WebConfigServer::probeBoardCommands() {
_board_cmds_probed = true;
}
void WebConfigServer::serviceSetupPassword(uint32_t now) {
if (_mode != MODE_SETUP || _stopping) return;
mesh::esp32::managementPassword::Snapshot credential;
if (!mesh::esp32::managementPassword::snapshot(credential)) return;
{
WCLock lock(_mux);
if (credential.generation == _ap_auth_generation || _reboot_at) return;
if (_ap_rekey_generation != credential.generation) {
_ap_rekey_generation = credential.generation;
_ap_rekey_after = WebConfigBatch::scheduleAt(now, 3000);
_ap_rekey_deadline = WebConfigBatch::scheduleAt(now, 30000);
}
if (_batch_state == BATCH_PENDING || handlerRefCount() != 0) return;
if (_batch_reauth_required && !_batch_reauth_delivered
&& !WebConfigBatch::deadlineReached(now, _ap_rekey_deadline)) return;
if (!WebConfigBatch::deadlineReached(now, _ap_rekey_after)) return;
}
// Reserve outside the password callback: it already owns a ChangeGuard.
// AP restart and an accepted OTA upload must never race one another.
mesh::esp32::managementPassword::ChangeGuard rekey;
if (!rekey.acquired()
|| !mesh::esp32::managementPassword::snapshot(credential)) return;
const char* password = mesh::esp32::managementPassword::apPasswordEligible(
credential.password) ? credential.password : nullptr;
const bool ready = mesh::wifi::startAccessPoint(_ap_ssid, true, password);
WCLock lock(_mux);
if (ready) {
_ap_auth_generation = credential.generation;
_ap_rekey_generation = _ap_rekey_after = _ap_rekey_deadline = 0;
if (!password) _setup_provisioning_privilege = false;
} else {
// An early SDK failure can leave the previous AP and its obsolete key live.
// Fail closed while retaining STA/the shared radio for the bounded retry.
WiFi.softAPdisconnect(true);
_ap_rekey_after = WebConfigBatch::scheduleAt(now, 5000);
mesh::usbDebugPort().println("WebConfig: AP password restart failed; retrying");
}
}
void WebConfigServer::tick(uint32_t now) {
refreshNetworkPowerHold(now);
serviceSetupPassword(now);
if (_stopping) {
uint32_t refs = handlerRefCount();
switch (WebConfigBatch::stopStep(refs, _stop_warned, _stop_warn_at, now)) {
@@ -1394,6 +1452,10 @@ void WebConfigServer::tick(uint32_t now) {
void WebConfigServer::finishBatch(uint32_t now) {
WCLock lock(_mux);
_batch_state = BATCH_DONE;
if (_batch_reauth_required) {
_batch_reauth_until = WebConfigBatch::scheduleAt(
now, WebConfigBatch::kReauthReceiptMs);
}
const uint32_t reboot_at =
WebConfigBatch::finishRebootAt(_batch_reboot, _batch_all_ok, now);
if (reboot_at != 0) {
@@ -1461,14 +1523,34 @@ void WebConfigServer::drainBatch(uint32_t now) {
// clients (iPhones especially) to drop off mid-save.
if (_batch_next == 0) {
WCLock prefs_lock(_mux);
mesh::esp32::managementPassword::Snapshot credential;
if (!mesh::esp32::managementPassword::snapshot(credential)
|| credential.generation != _batch_auth_generation) {
cancelStaleBatchLocked(now);
return;
}
_cb->onConfigBatchStart();
_batch_started = true;
} else if (WebConfigBatch::drainMustWait(_batch_next, _batch_count,
now, _batch_last_cmd)) {
return; // let the WiFi task breathe between flash writes
}
if (_batch_next < _batch_count) {
WCLock prefs_lock(_mux);
mesh::esp32::managementPassword::Snapshot credential;
if (!mesh::esp32::managementPassword::snapshot(credential)
|| credential.generation != _batch_auth_generation) {
cancelStaleBatchLocked(now);
return;
}
BatchEntry& e = _batch[_batch_next++];
const bool password_command = _batch_kind == BATCH_CLI
? wcCliEchoesSecret(e.cmd) : wcIsAdminPasswordKey(e.key);
char requested_password[16] = {};
if (password_command) {
strncpy(requested_password, e.cmd + strlen("password "),
sizeof(requested_password) - 1);
}
e.reply[0] = 0;
uint32_t t0 = millis();
if (_batch_kind == BATCH_CLI) {
@@ -1529,9 +1611,14 @@ void WebConfigServer::drainBatch(uint32_t now) {
}
} else {
_cb->execCommand(e.cmd, e.reply);
// The CLI password command echoes the new secret. Never return it to a
// browser client, especially over the open setup AP.
if (admin_pwd) strcpy(e.reply, "OK");
// Mask only a confirmed save. An error must remain visible and prevent
// the batch from rebooting into settings that may not have persisted.
if (admin_pwd && WebConfigBatch::cliPasswordChanged(e.reply)) {
strcpy(e.reply, "OK");
_admin_pwd_set = true;
} else if (admin_pwd && e.reply[0] == 0) {
strcpy(e.reply, "Error: password save was not confirmed");
}
// Keep the response snapshot current after MQTT/companion CLI handlers
// persist a WiFi field. The browser can then soft-reload without a stale
// value even before the requested reboot happens.
@@ -1549,6 +1636,27 @@ void WebConfigServer::drainBatch(uint32_t now) {
_batch_all_ok = WebConfigBatch::nextAllOk(
_batch_all_ok, strncmp(e.reply, "OK", 2) == 0);
}
mesh::esp32::managementPassword::Snapshot committed;
const bool have_committed = mesh::esp32::managementPassword::snapshot(committed);
if (!have_committed
|| committed.generation != _batch_auth_generation) {
if (have_committed && password_command && WebConfigBatch::cliPasswordChanged(e.reply)
&& fixedTimeEquals(committed.password, requested_password,
sizeof(committed.password))) {
// This accepted batch changed its own credential. Keep only a scoped
// receipt capability; the old cookie no longer authorizes any API.
_batch_auth_generation = committed.generation;
_batch_reauth_required = true;
_batch_reauth_until = WebConfigBatch::scheduleAt(
millis(), WebConfigBatch::kReauthReceiptMs);
clearSessionLocked();
} else {
memset(requested_password, 0, sizeof(requested_password));
cancelStaleBatchLocked(now);
return;
}
}
memset(requested_password, 0, sizeof(requested_password));
_batch_last_cmd = millis();
// Config entries are named by their (non-secret) key. A CLI command is
// deliberately not logged: the operator can see what they typed, and a
@@ -1588,6 +1696,7 @@ void WebConfigServer::drainBatch(uint32_t now) {
{
WCLock prefs_lock(_mux);
_cb->onConfigBatchEnd();
_batch_started = false;
}
bool wifi_credentials_changed = false;
@@ -1866,38 +1975,112 @@ void WebConfigServer::collectBody(AsyncWebServerRequest* req, uint8_t* data, siz
if (req->_tempObject) memcpy((uint8_t*)req->_tempObject + index, data, len);
}
bool WebConfigServer::checkAuth(AsyncWebServerRequest* req) {
_last_activity = millis();
void WebConfigServer::initializeManagementPassword() {
// Called only by loop-owned startup, after preferences have loaded. HTTP
// authentication must never read a setter's temporary, uncommitted RAM.
mesh::esp32::managementPassword::Snapshot current;
if (mesh::esp32::managementPassword::snapshot(current)) return;
NodeSnapshot node = {};
_cb->getNodeSnapshot(node);
mesh::esp32::managementPassword::initialize(node.admin_password);
}
void WebConfigServer::clearSessionLocked() {
memset(_session_token, 0, sizeof(_session_token));
_session_generation = 0;
_session_last_seen = 0;
}
static bool webConfigCookieMatches(AsyncWebServerRequest* req, const char* expected) {
if (!req || !expected || !expected[0] || !req->hasHeader("Cookie")) return false;
const String& cookies = req->getHeader("Cookie")->value();
const int idx = cookies.indexOf("wcs=");
if (idx < 0 || (int)cookies.length() < idx + 4 + 32) return false;
const String token = cookies.substring(idx + 4, idx + 4 + 32);
return fixedTimeEquals(token.c_str(), expected, 32);
}
bool WebConfigServer::checkAuthLocked(AsyncWebServerRequest* req, uint32_t* generation) {
if (_mode == MODE_SETUP) {
// AP+STA is used for scans and saved-network recovery. A first-time setup
// may provision a new password from the physical AP. Losing access to a
// saved WiFi network must not grant that same password-reset privilege.
if (!req || !req->client()
|| req->client()->localIP() != WiFi.softAPIP()) return false;
if (_initial_setup || _wifi_ssid[0] == 0) { noteNetworkActivity(); return true; }
} else if (_mode != MODE_LAN) {
return false;
}
NodeSnapshot node = {};
{
WCLock lock(_mux);
_cb->getNodeSnapshot(node);
mesh::esp32::managementPassword::Snapshot current;
if (!mesh::esp32::managementPassword::snapshot(current)) return false;
if (generation) *generation = current.generation;
if (_session_generation != current.generation) clearSessionLocked();
if ((_mode == MODE_SETUP && _setup_provisioning_privilege
&& _ap_auth_generation == current.generation
&& mesh::esp32::managementPassword::apPasswordEligible(current.password))
|| current.password[0] == 0) {
noteNetworkActivity();
return true;
}
if (node.admin_password[0] == 0) { noteNetworkActivity(); return true; }
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;
const uint32_t now = millis();
if ((uint32_t)(now - _session_last_seen) > WEBCONFIG_SESSION_TTL_MS) {
clearSessionLocked();
return false;
}
if (!webConfigCookieMatches(req, _session_token)) return false;
_session_last_seen = now; // sliding expiry
noteNetworkActivity();
return true;
}
bool WebConfigServer::checkAuth(AsyncWebServerRequest* req) {
_last_activity = millis();
WCLock lock(_mux);
return checkAuthLocked(req);
}
void WebConfigServer::beginBatchAuthorizationLocked(uint32_t generation) {
_batch_auth_generation = generation;
memcpy(_batch_owner_token, _session_token, sizeof(_batch_owner_token));
_batch_reauth_required = false;
_batch_reauth_delivered = false;
_batch_reauth_until = 0;
_batch_started = false;
}
void WebConfigServer::cancelStaleBatchLocked(uint32_t now) {
while (_batch_next < _batch_count) {
strcpy(_batch[_batch_next++].reply,
"Error: administrator password changed; sign in again");
}
_batch_all_ok = false;
_batch_reauth_required = false;
_batch_reauth_delivered = false;
_batch_reauth_until = 0;
memset(_batch_owner_token, 0, sizeof(_batch_owner_token));
if (_standalone_wifi_dirty) {
_standalone_wifi_dirty = false;
reloadStandaloneWiFi();
}
if (_batch_started) _cb->onConfigBatchEnd();
_batch_started = false;
_batch_state = BATCH_DONE;
(void)now;
}
bool WebConfigServer::resultReceiptAllowedLocked(AsyncWebServerRequest* req,
const char* reqid, BatchKind kind, uint32_t now) {
if (_mode != MODE_SETUP && _mode != MODE_LAN) return false;
if (!_batch_reauth_required || _batch_kind != kind || !reqid
|| strcmp(reqid, _batch_reqid) != 0 || !_batch_reauth_until
|| WebConfigBatch::deadlineReached(now, _batch_reauth_until)
|| !webConfigCookieMatches(req, _batch_owner_token)) return false;
if (_mode == MODE_SETUP && (!req->client()
|| req->client()->localIP() != WiFi.softAPIP())) return false;
mesh::esp32::managementPassword::Snapshot current;
return mesh::esp32::managementPassword::snapshot(current)
&& current.generation == _batch_auth_generation;
}
// ---------------------------------------------------------------------------
// Handlers (async_tcp task - no CLI/prefs writes, no radio access)
// ---------------------------------------------------------------------------
@@ -1969,12 +2152,29 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
}
const bool has_mqtt = _mqtt_prefs != NULL;
DynamicJsonDocument doc(896);
DynamicJsonDocument doc(1024);
doc["mode"] = (_mode == MODE_SETUP) ? "setup" : "lan";
doc["auth"] = authed;
doc["needs_setup"] = (_wifi_ssid[0] == 0);
doc["needs_password"] = _initial_setup;
doc["password_supported"] = node.admin_password[0] != 0;
mesh::esp32::managementPassword::Snapshot credential;
const bool have_credential = mesh::esp32::managementPassword::snapshot(credential);
doc["password_supported"] = have_credential && credential.password[0] != 0;
if (_mode == MODE_SETUP) {
char joined_password[65] = {};
const bool known = mesh::wifi::accessPointJoinPassword(
_ap_ssid, joined_password, sizeof(joined_password));
doc["ap_security"] = !known ? "unavailable" : joined_password[0]
? "admin_password" : have_credential && credential.password[0]
? "open_admin_login" : "open_no_password";
memset(joined_password, 0, sizeof(joined_password));
WCLock lock(_mux);
doc["ap_restart_pending"] = _ap_rekey_generation != 0 && !_reboot_at;
if (_ap_rekey_generation && !_reboot_at && _ap_rekey_after) {
doc["ap_restart_in_ms"] = WebConfigBatch::deadlineReached(millis(), _ap_rekey_after)
? 1u : _ap_rekey_after - millis();
}
}
doc["name"] = (const char*)node.name;
char node_id[17];
for (int i = 0; i < 8; i++) sprintf(&node_id[i * 2], "%02x", _pub_key[i]);
@@ -2050,18 +2250,17 @@ void WebConfigServer::handleLogin(AsyncWebServerRequest* req) {
req->send(401, "application/json", "{\"error\":\"auth\"}");
return;
}
if (_initial_setup || _wifi_ssid[0] == 0) {
noteNetworkActivity();
req->send(200, "application/json", "{\"ok\":true}");
return;
}
}
NodeSnapshot node = {};
{
WCLock lock(_mux);
_cb->getNodeSnapshot(node);
WCLock lock(_mux);
mesh::esp32::managementPassword::Snapshot credential;
if (!mesh::esp32::managementPassword::snapshot(credential)) {
req->send(503, "application/json", "{\"error\":\"credentials unavailable\"}");
return;
}
if (node.admin_password[0] == 0) {
if (credential.password[0] == 0 || (_mode == MODE_SETUP
&& _setup_provisioning_privilege
&& _ap_auth_generation == credential.generation
&& mesh::esp32::managementPassword::apPasswordEligible(credential.password))) {
noteNetworkActivity();
req->send(200, "application/json", "{\"ok\":true}");
return;
@@ -2086,14 +2285,26 @@ void WebConfigServer::handleLogin(AsyncWebServerRequest* req) {
return;
}
const char* pwd = doc["password"] | "";
if (!fixedTimeEquals(pwd, node.admin_password, sizeof(node.admin_password))) {
// Parsing can yield to the mesh loop. Verify the current committed mirror
// again before publishing a token, not a pre-parse preference snapshot.
if (!mesh::esp32::managementPassword::snapshot(credential)
|| !fixedTimeEquals(pwd, credential.password, sizeof(credential.password))) {
_login_throttle.noteFailure(client, now);
req->send(401, "application/json", "{\"error\":\"wrong password\"}");
return;
}
_login_throttle.noteSuccess(client);
for (int i = 0; i < 4; i++) sprintf(&_session_token[i * 8], "%08lx", (unsigned long)esp_random());
char token[33] = {};
for (int i = 0; i < 4; i++) sprintf(&token[i * 8], "%08lx", (unsigned long)esp_random());
mesh::esp32::managementPassword::Snapshot latest;
if (!mesh::esp32::managementPassword::snapshot(latest)
|| latest.generation != credential.generation) {
req->send(401, "application/json", "{\"error\":\"password changed; sign in again\"}");
return;
}
memcpy(_session_token, token, sizeof(_session_token));
_session_last_seen = now;
_session_generation = credential.generation;
noteNetworkActivity();
AsyncWebServerResponse* res = req->beginResponse(200, "application/json", "{\"ok\":true}");
@@ -2105,8 +2316,9 @@ void WebConfigServer::handleLogin(AsyncWebServerRequest* req) {
void WebConfigServer::handleLogout(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
_session_token[0] = 0;
WCLock lock(_mux);
if (!checkAuthLocked(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
clearSessionLocked();
AsyncWebServerResponse* res = req->beginResponse(200, "application/json", "{\"ok\":true}");
res->addHeader("Set-Cookie", "wcs=; Max-Age=0; Path=/");
req->send(res);
@@ -2262,6 +2474,11 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
}
WCLock lock(_mux);
uint32_t auth_generation = 0;
if (!checkAuthLocked(req, &auth_generation)) {
req->send(401, "application/json", "{\"error\":\"auth\"}");
return;
}
const WebConfigBatch::State bstate = toSpecState(_batch_state);
const bool reqid_matches = _batch_kind == BATCH_CONFIG
&& strcmp(reqid, _batch_reqid) == 0;
@@ -2394,6 +2611,7 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
_batch_reboot = reboot_after;
_batch_reboot_armed = false;
_batch_all_ok = true;
beginBatchAuthorizationLocked(auth_generation);
strncpy(_batch_reqid, reqid, sizeof(_batch_reqid) - 1);
_batch_reqid[sizeof(_batch_reqid) - 1] = 0;
_standalone_wifi_dirty = false;
@@ -2423,11 +2641,6 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
req->send(503);
return;
}
if (!checkAuth(req)) {
mesh::usbDebugPort().println("WC: result read -> 401");
req->send(401, "application/json", "{\"error\":\"auth\"}");
return;
}
if (!req->hasParam("reqid")) {
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
return;
@@ -2444,6 +2657,12 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
"WC: result entry mode=%d state=%d\n",
(int)_mode, (int)_batch_state);
WCLock lock(_mux);
const bool authenticated = checkAuthLocked(req);
if (!authenticated && !resultReceiptAllowedLocked(
req, requested_reqid.c_str(), BATCH_CONFIG, millis())) {
req->send(401, "application/json", "{\"error\":\"auth\"}");
return;
}
// A CLI sequence occupying the shared slot is not a config save, whatever the
// reqid says: its entries have no `key` and its results belong to the
// terminal's reader. Treat it as unknown here (and vice versa there).
@@ -2469,6 +2688,7 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
StaticJsonDocument<96> pending;
pending["state"] = "pending";
pending["reqid"] = (const char*)_batch_reqid;
pending["reauth_required"] = _batch_reauth_required && !authenticated;
String out;
serializeJson(pending, out);
req->send(200, "application/json", out);
@@ -2483,6 +2703,12 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
doc["reboot"] =
WebConfigBatch::doneReportsReboot(_batch_reboot, _batch_all_ok);
doc["all_ok"] = _batch_all_ok;
doc["reauth_required"] = _batch_reauth_required && !authenticated;
if (_batch_reauth_required && !_batch_reauth_delivered) {
_batch_reauth_delivered = true;
if (_ap_rekey_generation)
_ap_rekey_after = WebConfigBatch::scheduleAt(millis(), 3000);
}
doc["reqid"] = (const char*)_batch_reqid;
if (_setup_wifi_handoff_ip[0]) {
doc["wifi_ip"] = (const char*)_setup_wifi_handoff_ip;
@@ -2491,7 +2717,9 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
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;
r["reply"] = authenticated ? (const char*)_batch[i].reply
: WebConfigBatch::cliWriteSucceeded(_batch[i].reply) ? "OK"
: "Error: setting failed; sign in again for details";
}
// State stays DONE (re-readable) until the next POST claims the slot.
const uint32_t result_now = millis();
@@ -2549,6 +2777,12 @@ void WebConfigServer::closeTerminal() {
void WebConfigServer::serviceTerminal(uint32_t now) {
WCLock lock(_mux);
if (!_terminal) return;
mesh::esp32::managementPassword::Snapshot credential;
if (!mesh::esp32::managementPassword::snapshot(credential)
|| credential.generation != _terminal_auth_generation) {
closeTerminal();
return;
}
if (now - _terminal->last_seen > (_terminal->closed ? 5000UL : 60000UL)) {
closeTerminal();
return;
@@ -2644,6 +2878,15 @@ void WebConfigServer::handleTerminalPost(AsyncWebServerRequest* req) {
return;
}
WCLock lock(_mux);
uint32_t auth_generation = 0;
if (!checkAuthLocked(req, &auth_generation)) {
req->send(401, "application/json", "{\"error\":\"auth\"}");
return;
}
if (_terminal && _terminal_auth_generation != auth_generation) {
req->send(409, "application/json", "{\"error\":\"terminal session expired after password change\"}");
return;
}
if (_terminal && strcmp(session, _terminal->session) != 0) {
if (!_terminal->closed || _terminal->attached) {
req->send(409, "application/json", "{\"error\":\"another browser owns the terminal\"}");
@@ -2666,6 +2909,7 @@ void WebConfigServer::handleTerminalPost(AsyncWebServerRequest* req) {
return;
}
strlcpy(_terminal->session, session, sizeof(_terminal->session));
_terminal_auth_generation = auth_generation;
}
if (_terminal->closed) {
req->send(409, "application/json", "{\"error\":\"terminal session closed\"}");
@@ -2757,6 +3001,11 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) {
}
WCLock lock(_mux);
uint32_t auth_generation = 0;
if (!checkAuthLocked(req, &auth_generation)) {
req->send(401, "application/json", "{\"error\":\"auth\"}");
return;
}
// Replay/Busy exactly as a config save classifies them: a repeated POST is
// acknowledged rather than executed twice, and a different sequence while one
// is still draining is refused.
@@ -2856,6 +3105,7 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) {
_batch_reboot = defer_reboot;
_batch_reboot_armed = false;
_batch_all_ok = true;
beginBatchAuthorizationLocked(auth_generation);
strncpy(_batch_reqid, reqid, sizeof(_batch_reqid) - 1);
_batch_reqid[sizeof(_batch_reqid) - 1] = 0;
_batch_state = BATCH_PENDING; // tick() picks it up on the loop task
@@ -2888,7 +3138,6 @@ void WebConfigServer::handleCliResult(AsyncWebServerRequest* req) {
"{\"error\":\"terminal disabled; set wifi.cli on\"}");
return;
}
if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
if (!req->hasParam("reqid")) {
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
return;
@@ -2905,6 +3154,13 @@ void WebConfigServer::handleCliResult(AsyncWebServerRequest* req) {
}
WCLock lock(_mux);
const bool authenticated = checkAuthLocked(req);
const bool receipt_only = !authenticated && resultReceiptAllowedLocked(
req, requested_reqid.c_str(), BATCH_CLI, millis());
if (!authenticated && !receipt_only) {
req->send(401, "application/json", "{\"error\":\"auth\"}");
return;
}
// A config save occupying the slot is not this client's sequence, whatever
// the reqid says; treat it as unknown rather than serving `set` results
// through the terminal's reader.
@@ -2937,15 +3193,27 @@ void WebConfigServer::handleCliResult(AsyncWebServerRequest* req) {
doc["reqid"] = (const char*)_batch_reqid;
doc["total"] = _batch_count;
doc["from"] = from;
doc["reauth_required"] = _batch_reauth_required && !authenticated;
JsonArray results = doc.createNestedArray("results");
for (int i = from; i < from + page; i++) {
JsonObject r = results.createNestedObject();
// The command is deliberately NOT echoed: it may hold a password or token,
// and the client already has the sequence it sent. It matches by index.
r["ok"] = !WebConfigBatch::cliReplyIsFailure(_batch[i].reply);
r["reply"] = (const char*)_batch[i].reply;
const bool failed = WebConfigBatch::cliReplyIsFailure(_batch[i].reply);
r["ok"] = !failed;
// Receipt authority is deliberately weaker than an authenticated terminal.
// Do not rely on a growing list of secret getters or echoing commands.
r["reply"] = !receipt_only ? (const char*)_batch[i].reply
: failed ? "Error: command failed; sign in again for details"
: WebConfigBatch::cliWriteSucceeded(_batch[i].reply) ? "OK"
: "Sign in again to read this result";
}
if (final_read) {
if (_batch_reauth_required && !_batch_reauth_delivered) {
_batch_reauth_delivered = true;
if (_ap_rekey_generation)
_ap_rekey_after = WebConfigBatch::scheduleAt(millis(), 3000);
}
doc["all_ok"] = _batch_all_ok;
const bool rebooting = WebConfigBatch::cliRebootAllowed(_batch_reboot, _batch_all_ok);
doc["reboot"] = rebooting;
+29 -4
View File
@@ -5,18 +5,21 @@
// companion builds. The UI removes MQTT controls when no bridge is present.
//
// Two modes:
// - SETUP: open SoftAP + captive portal, raised automatically on first boot
// - SETUP: admin-password SoftAP + captive portal, raised on first boot
// when no WiFi is configured (wifi_ssid empty), or manually via
// `start webconfig ap`. A credential save joins in AP+STA mode, reports the
// assigned LAN IP, then reboots; the AP auto-stops after an idle timeout.
// Legacy short/non-ASCII admin credentials use open WiFi plus HTTP login.
// - LAN: bound to an existing or portal-owned STA connection. Infrastructure
// roles require the node admin password; companions use their trusted LAN.
// roles use the committed node admin password. Explicit legacy passwordless
// configurations retain their local no-password policy.
//
// Concurrency model: AsyncWebServer handlers run on the async_tcp task and
// must never touch the CLI, prefs persistence, or the radio. Config writes
// and browser-terminal commands are marshaled to tick() - called from
// MyMesh::loop() on the Arduino loop task - and run through the existing CLI
// handlers there. Prefs-struct reads and request state are guarded by _mux.
// handlers there. Request state is guarded by _mux; authentication uses the
// separately published committed credential generation, never temporary prefs.
//
// The master switch and standalone WiFi credentials use independent NVS
// namespaces, so role-specific prefs-file layouts remain untouched.
@@ -317,6 +320,7 @@ private:
bool _keep_wifi_on_stop = false;
bool _was_setup_ap = false;
bool _initial_setup = false;
bool _setup_provisioning_privilege = false;
uint32_t _setup_started_at = 0;
uint32_t _connect_deadline = 0;
char _wifi_ssid[32] = {0};
@@ -329,6 +333,10 @@ private:
// sends the password with the rest, so it never needed the memory.
bool _admin_pwd_set = false;
char _ap_ssid[33] = {0};
uint32_t _ap_auth_generation = 0;
uint32_t _ap_rekey_generation = 0;
uint32_t _ap_rekey_after = 0;
uint32_t _ap_rekey_deadline = 0;
WiFiReconnectPolicy::Tracker _wifi_reconnect_tracker;
bool _retry_saved_wifi_in_setup = false;
bool _setup_reconnect_in_progress = false;
@@ -349,7 +357,13 @@ private:
bool _batch_reboot = false;
bool _batch_reboot_armed = false;
bool _batch_all_ok = true;
bool _batch_started = false;
char _batch_reqid[24] = {0};
uint32_t _batch_auth_generation = 0;
char _batch_owner_token[33] = {0};
bool _batch_reauth_required = false;
bool _batch_reauth_delivered = false;
uint32_t _batch_reauth_until = 0;
bool _standalone_wifi_dirty = false;
bool _setup_wifi_handoff_pending = false;
uint32_t _setup_wifi_handoff_deadline = 0;
@@ -358,9 +372,12 @@ private:
WebTerminalStream* _terminal = NULL;
mesh::RoomWebMailbox* _room_mailbox = NULL;
// LAN-mode session (single slot; new login evicts the old session)
// Browser login session, bound to the committed credential generation.
// One slot: a new login evicts the old session in LAN or recovery AP mode.
char _session_token[33] = {0};
uint32_t _session_last_seen = 0;
uint32_t _session_generation = 0;
uint32_t _terminal_auth_generation = 0;
WebConfigLoginThrottle::Tracker _login_throttle;
// Once set (via /api/portal/exit), OS captive probes get native "success"
@@ -401,9 +418,17 @@ private:
void serviceRoom(uint32_t now);
void closeTerminal();
void serviceSetupWiFiHandoff(uint32_t now);
void serviceSetupPassword(uint32_t now);
void finishBatch(uint32_t now);
void finalizeTeardown();
void initializeManagementPassword();
void clearSessionLocked();
bool checkAuth(AsyncWebServerRequest* req);
bool checkAuthLocked(AsyncWebServerRequest* req, uint32_t* generation = nullptr);
bool resultReceiptAllowedLocked(AsyncWebServerRequest* req, const char* reqid,
BatchKind kind, uint32_t now);
void beginBatchAuthorizationLocked(uint32_t generation);
void cancelStaleBatchLocked(uint32_t now);
static void collectBody(AsyncWebServerRequest* req, uint8_t* data, size_t len,
size_t index, size_t total);
+51 -6
View File
@@ -2,6 +2,7 @@
#include <WiFi.h>
#include <helpers/esp32/WiFiRadioPolicy.h>
#include <helpers/esp32/ManagementPassword.h>
#include <string.h>
namespace mesh {
@@ -17,7 +18,7 @@ inline bool accessPointStarted() {
// Arduino setters and a configured netif IP can outlive the AP_START event.
// Read the live driver before telling an operator that the hotspot is ready.
inline bool openAccessPointReady(const char* ssid, int channel) {
inline bool accessPointReady(const char* ssid, int channel, const char* password) {
wifi_mode_t mode = WIFI_MODE_NULL;
wifi_config_t config = {};
uint8_t protocol = 0;
@@ -27,13 +28,43 @@ inline bool openAccessPointReady(const char* ssid, int channel) {
&& esp_wifi_get_config(WIFI_IF_AP, &config) == ESP_OK
&& config.ap.ssid_len == length
&& memcmp(config.ap.ssid, ssid, length) == 0
&& config.ap.ssid_hidden == 0 && config.ap.authmode == WIFI_AUTH_OPEN
&& config.ap.ssid_hidden == 0
&& config.ap.authmode == (password && password[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN)
&& (!password || !password[0]
|| strncmp(reinterpret_cast<const char*>(config.ap.password), password,
sizeof(config.ap.password)) == 0)
&& config.ap.channel == channel
&& esp_wifi_get_protocol(WIFI_IF_AP, &protocol) == ESP_OK
&& protocol == kAccessPointProtocolMask
&& static_cast<uint32_t>(WiFi.softAPIP()) != 0;
}
inline bool openAccessPointReady(const char* ssid, int channel) {
return accessPointReady(ssid, channel, nullptr);
}
// Physical join QR codes must describe the AP that is actually running. A
// freshly saved admin password can precede its deferred AP rekey by 750ms.
inline bool accessPointJoinPassword(const char* ssid, char* output, size_t capacity) {
if (!output || !capacity) return false;
output[0] = 0;
if (!ssid || !ssid[0] || !accessPointStarted()) return false;
wifi_config_t config = {};
const size_t ssid_length = strlen(ssid);
if (ssid_length > sizeof(config.ap.ssid)) return false;
if (esp_wifi_get_config(WIFI_IF_AP, &config) != ESP_OK
|| config.ap.ssid_len != ssid_length
|| memcmp(config.ap.ssid, ssid, config.ap.ssid_len) != 0) return false;
if (config.ap.authmode == WIFI_AUTH_OPEN) return true;
if (config.ap.authmode != WIFI_AUTH_WPA2_PSK) return false;
size_t length = 0;
while (length < sizeof(config.ap.password) && config.ap.password[length]) ++length;
if (length < 8 || length == sizeof(config.ap.password) || length >= capacity) return false;
memcpy(output, config.ap.password, length);
output[length] = 0;
return true;
}
inline bool finishAccessPointScan() {
// A failed Arduino scan can still have an active SDK scanner. scanDelete()
// only releases results; cancel that scanner before enabling AP beacons.
@@ -58,12 +89,12 @@ inline bool scanBeforeAccessPoint() {
return finishAccessPointScan();
}
inline bool startOpenAccessPoint(const char* ssid, bool reuse_existing) {
inline bool startAccessPoint(const char* ssid, bool reuse_existing, const char* password) {
if (!accessPointCompatibleWithLongRange()) return false;
const int channel = accessPointChannel();
// Repeated OTA commands retain joined clients when the AP is already live.
if (reuse_existing && applyAccessPointProtocolMask() == ESP_OK
&& openAccessPointReady(ssid, channel)) return true;
&& accessPointReady(ssid, channel, password)) return true;
const bool connected = WiFi.status() == WL_CONNECTED
&& static_cast<uint32_t>(WiFi.localIP()) != 0;
@@ -82,13 +113,13 @@ inline bool startOpenAccessPoint(const char* ssid, bool reuse_existing) {
const IPAddress ip(192, 168, 4, 1);
const IPAddress mask(255, 255, 255, 0);
bool ready = WiFi.softAPConfig(ip, ip, mask)
&& WiFi.softAP(ssid, nullptr, channel)
&& WiFi.softAP(ssid, password, channel)
&& applyAccessPointProtocolMask() == ESP_OK;
if (ready) {
ready = false;
const uint32_t started = millis();
do {
ready = openAccessPointReady(ssid, channel);
ready = accessPointReady(ssid, channel, password);
if (!ready) delay(20);
} while (!ready && millis() - started < 300);
}
@@ -96,6 +127,20 @@ inline bool startOpenAccessPoint(const char* ssid, bool reuse_existing) {
return ready;
}
inline bool startOpenAccessPoint(const char* ssid, bool reuse_existing) {
return startAccessPoint(ssid, reuse_existing, nullptr);
}
// Existing short/non-ASCII admin passwords retain HTTP authentication and an
// explicitly reported open-AP compatibility path; never invent a second secret.
inline bool startManagementAccessPoint(const char* ssid, bool reuse_existing) {
mesh::esp32::managementPassword::Snapshot current;
if (!mesh::esp32::managementPassword::snapshot(current)) return false;
const char* password = mesh::esp32::managementPassword::apPasswordEligible(current.password)
? current.password : nullptr;
return startAccessPoint(ssid, reuse_existing, password);
}
inline void stopTemporaryAccessPointRadio(bool started_radio) {
// Removing AP releases its shared LR constraint. The station policy checks
// the live SDK mode and cannot re-enable LR while AP is still present.
+20 -6
View File
@@ -2,10 +2,29 @@
#include "DisplayDriver.h"
#include "WiFiSetupQrPayload.h"
#if defined(ESP_PLATFORM) || defined(ESP32_PLATFORM)
#include "../esp32/WiFiAccessPointPolicy.h"
#endif
namespace mesh {
namespace ui {
inline bool buildCurrentWiFiSetupQrPayload(char* payload, size_t payload_size,
const char* ssid) {
#if defined(ESP_PLATFORM) || defined(ESP32_PLATFORM)
char password[65];
if (!mesh::wifi::accessPointJoinPassword(ssid, password, sizeof(password)))
return false;
return buildWiFiSetupQrPayload(payload, payload_size, ssid, password);
#else
const char* password = nullptr;
#ifdef WEBCONFIG_AP_PASSWORD
password = WEBCONFIG_AP_PASSWORD;
#endif
return buildWiFiSetupQrPayload(payload, payload_size, ssid, password);
#endif
}
// Render a setup-AP join QR at the largest size that still leaves the right
// half of compact displays available for a short textual fallback/reference.
// A Version 1 QR, including its four-module quiet zone, is exactly 58 pixels
@@ -16,12 +35,7 @@ inline bool drawWiFiSetupQr(DisplayDriver& display, const char* ssid,
const char* address, bool compact_border = false,
const char* action = nullptr) {
char payload[256];
const char* password = nullptr;
#ifdef WEBCONFIG_AP_PASSWORD
password = WEBCONFIG_AP_PASSWORD;
#endif
if (!buildWiFiSetupQrPayload(
payload, sizeof(payload), ssid, password)) {
if (!buildCurrentWiFiSetupQrPayload(payload, sizeof(payload), ssid)) {
return false;
}