Improve emergency telemetry and password controls

This commit is contained in:
mikecarper committed 2026-11-02 23:28:12 -08:00
1 parent 236d12610c
commit dbaf951359
20 files changed
+3320 -2436

No files matched your search

+3
View File
@@ -710,6 +710,7 @@ jobs:
python3 -B test/test_solar_daylight.py -v
python3 -B test/test_power_schedule.py -v
python3 -B test/test_emergency_power_runtime.py -v
python3 -B test/test_emergency_telemetry_sampling.py -v
python3 -B test/test_emergency_power_ota.py -v
python3 -B test/test_emergency_power_bench.py -v
python3 -B test/test_emergency_sleep_backends.py -v
@@ -1049,6 +1050,8 @@ jobs:
python3 -B test/test_service_start_retry.py -v
python3 -B test/test_webconfig_recovery_auth.py -v
python3 -B test/test_webconfig_password_sessions.py -v
python3 -B test/test_webconfig_password_persistence.py -v
python3 -B test/test_webconfig_password_reveal.py -v
python3 -B test/test_webconfig_login_throttle.py -v
python3 -B test/test_esp32_wifi_scan_fix.py -v
python3 -B test/test_esp32_wifi_scan_recipes.py -v
+3
View File
@@ -182,6 +182,9 @@ There is no separate setup WiFi password. A protected first-time setup AP with
no saved SSID retains its provisioning flow; recovery with a saved SSID requires
the existing admin login. Deliberately passwordless images retain their local
no-password policy. The portal uses plain HTTP, so use a trusted network.
The password fields show a mask when a password is saved. After signing in,
tap the eye to view it or type a new password and confirmation to change it.
Viewing the saved value does not submit a password change.
Changing the password ends existing browser sessions after the save succeeds.
The submitting page keeps the save result and tries the new password once,
+6
View File
@@ -3413,6 +3413,12 @@ the same 15/45-minute cycle from boot. Keep the clock set to UTC: a plausible
timestamp alone does not prove it is synchronized. Queued
future traffic does not keep a critically low node awake indefinitely.
Repeater and Room builds with telemetry history record battery voltage and MCU
temperature every 30 minutes during emergency sleep. Sampling uses an existing
maintenance wake and leaves the radio asleep. External voltage sensors and GPS
are not polled for these sleep samples. History is held in RAM and is lost on
reboot.
This schedule uses resumable timed sleep, preserving identity and settings.
WiFi/ESP-NOW services are temporarily stopped and their previous live subset
is restored when the node wakes. Companion builds and flash-constrained
+6 -2
View File
@@ -13932,7 +13932,7 @@ void MyMesh::sampleNoiseFloorHistory() {
rtc_clock.getCurrentTime(), millis());
}
void MyMesh::sampleTelemetryHistory() {
void MyMesh::sampleTelemetryHistory(bool emergency_only) {
const uint32_t now = rtc_clock.getCurrentTime();
if (!telemetry_history.sampleDue(now)) return;
@@ -13940,7 +13940,7 @@ void MyMesh::sampleTelemetryHistory() {
int32_t longitude_e7 = 0;
bool gps_valid = false;
#if ENV_INCLUDE_GPS == 1
LocationProvider* location = sensors.getLocationProvider();
LocationProvider* location = emergency_only ? NULL : sensors.getLocationProvider();
if (location != NULL && location->isEnabled() && location->isValid()) {
const long latitude_e6 = location->getLatitude();
const long longitude_e6 = location->getLongitude();
@@ -13966,6 +13966,10 @@ void MyMesh::sampleTelemetryHistory() {
_cli.getBoard()->getBattMilliVolts(),
latitude_e7, longitude_e7, gps_valid);
// Do not start external I2C sensors during a low-battery wake. Their
// unsampled buckets remain missing in the separate voltage history.
if (emergency_only) return;
SensorManager::VoltageSensorReading sensor_readings[
mesh::ExternalVoltageHistory::MAX_CHANNELS];
const uint8_t reading_count = sensors.queryVoltageSensors(
+6 -1
View File
@@ -1001,7 +1001,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
bool allow_redundant_copies = true);
void servicePostMeshLoop();
#if MESH_ENABLE_TELEMETRY_HISTORY
void sampleTelemetryHistory();
void sampleTelemetryHistory(bool emergency_only = false);
void sampleNoiseFloorHistory();
#if MESH_ENABLE_TELEMETRY_GPS_HISTORY
uint8_t resizeTelemetryGpsDays(uint8_t requested_days);
@@ -1311,6 +1311,11 @@ public:
#if MESH_ENABLE_EMERGENCY_POWER
mesh::power::EmergencyOtaWork _emergency_ota_work;
CommonCLI& getPowerCLI() { return _cli; }
void sampleEmergencyTelemetry() {
#if MESH_ENABLE_TELEMETRY_HISTORY
sampleTelemetryHistory(true);
#endif
}
bool emergencyPowerBusy() const {
#if defined(ENABLE_OTA)
if (_emergency_ota_work.busy(mesh::ota::ota_context_if_active(), millis())) return true;
+5 -1
View File
@@ -799,7 +799,11 @@ void loop() {
emergency_busy = emergency_busy || the_mesh.hasPendingSerialOutput();
#endif
if (emergency_power.service(the_mesh, radio_driver, board,
the_mesh.getPowerCLI(), emergency_busy, the_mesh.emergencyPowerBusy())) return;
the_mesh.getPowerCLI(), emergency_busy, the_mesh.emergencyPowerBusy())) {
// The radio stays asleep; reuse the existing CPU maintenance wake.
if (the_mesh.getPowerCLI().emergencyActive()) the_mesh.sampleEmergencyTelemetry();
return;
}
#endif
#if defined(PIN_USER_BTN) && defined(_SEEED_SENSECAP_SOLAR_H_) && !defined(DISPLAY_CLASS)
+6 -2
View File
@@ -3977,7 +3977,7 @@ void MyMesh::sampleNoiseFloorHistory() {
getRTCClock()->getCurrentTime(), millis());
}
void MyMesh::sampleTelemetryHistory() {
void MyMesh::sampleTelemetryHistory(bool emergency_only) {
const uint32_t now = getRTCClock()->getCurrentTime();
if (!telemetry_history.sampleDue(now)) return;
@@ -3985,7 +3985,7 @@ void MyMesh::sampleTelemetryHistory() {
int32_t longitude_e7 = 0;
bool gps_valid = false;
#if ENV_INCLUDE_GPS == 1
LocationProvider* location = sensors.getLocationProvider();
LocationProvider* location = emergency_only ? NULL : sensors.getLocationProvider();
if (location != NULL && location->isEnabled() && location->isValid()) {
const long latitude_e6 = location->getLatitude();
const long longitude_e6 = location->getLongitude();
@@ -4012,6 +4012,10 @@ void MyMesh::sampleTelemetryHistory() {
_cli.getBoard()->getBattMilliVolts(),
latitude_e7, longitude_e7, gps_valid);
// Do not start external I2C sensors during a low-battery wake. Their
// unsampled buckets remain missing in the separate voltage history.
if (emergency_only) return;
SensorManager::VoltageSensorReading sensor_readings[
mesh::ExternalVoltageHistory::MAX_CHANNELS];
const uint8_t reading_count = sensors.queryVoltageSensors(
+6 -1
View File
@@ -539,7 +539,7 @@ protected:
#if MESH_ENABLE_TELEMETRY_HISTORY
bool handleTelemetryHistoryCommand(const char* command, char* reply,
size_t reply_capacity = 160, bool include_prompt = true);
void sampleTelemetryHistory();
void sampleTelemetryHistory(bool emergency_only = false);
void sampleNoiseFloorHistory();
#endif
@@ -571,6 +571,11 @@ public:
#if MESH_ENABLE_EMERGENCY_POWER
mesh::power::EmergencyOtaWork _emergency_ota_work;
CommonCLI& getPowerCLI() { return _cli; }
void sampleEmergencyTelemetry() {
#if MESH_ENABLE_TELEMETRY_HISTORY
sampleTelemetryHistory(true);
#endif
}
bool emergencyPowerBusy() const {
#if defined(ENABLE_OTA)
if (_emergency_ota_work.busy(mesh::ota::ota_context_if_active(), millis())) return true;
+5 -1
View File
@@ -486,7 +486,11 @@ void loop() {
emergency_busy = emergency_busy || the_mesh.hasPendingSerialOutput();
#endif
if (emergency_power.service(the_mesh, radio_driver, board,
the_mesh.getPowerCLI(), emergency_busy, the_mesh.emergencyPowerBusy())) return;
the_mesh.getPowerCLI(), emergency_busy, the_mesh.emergencyPowerBusy())) {
// The radio stays asleep; reuse the existing CPU maintenance wake.
if (the_mesh.getPowerCLI().emergencyActive()) the_mesh.sampleEmergencyTelemetry();
return;
}
#endif
the_mesh.loop();
const bool morse_timing = mesh::morseTimingActive(&radio_driver);
File diff suppressed because it is too large. Load diff
+84 -1
View File
@@ -1989,6 +1989,9 @@ void WebConfigServer::registerRoutes() {
dispatchRequest(r, &WebConfigServer::handleLogin);
},
NULL, collectBody);
_server->on("/api/admin-password/reveal", HTTP_GET, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleAdminPasswordReveal);
});
_server->on("/api/logout", HTTP_POST, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleLogout);
});
@@ -2443,7 +2446,11 @@ void WebConfigServer::handleLogin(AsyncWebServerRequest* req) {
req->send(503, "application/json", "{\"error\":\"credentials unavailable\"}");
return;
}
if (credential.password[0] == 0 || (_mode == MODE_SETUP
// Provisioning retains its historical no-password bypass. A caller that
// explicitly asks to authenticate must prove the password and get a cookie.
const bool explicit_login = req->hasHeader("X-WebConfig-Explicit-Login")
&& req->getHeader("X-WebConfig-Explicit-Login")->value() == "1";
if (credential.password[0] == 0 || (!explicit_login && _mode == MODE_SETUP
&& _setup_provisioning_privilege
&& _ap_auth_generation == credential.generation
&& mesh::esp32::managementPassword::apPasswordEligible(credential.password))) {
@@ -2516,6 +2523,82 @@ void WebConfigServer::handleLogin(AsyncWebServerRequest* req) {
req->send(res);
}
void WebConfigServer::handleAdminPasswordReveal(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
// The non-simple header forces CORS preflight for cross-origin fetches and
// ensures a browser only requests the secret in response to an explicit UI action.
if (!req->hasHeader("X-WebConfig-Reveal")
|| req->getHeader("X-WebConfig-Reveal")->value() != "1"
|| (req->hasHeader("Origin")
&& (!req->hasHeader("Host") || req->getHeader("Origin")->value()
!= String("http://") + req->getHeader("Host")->value()))
|| (req->hasHeader("Sec-Fetch-Site")
&& req->getHeader("Sec-Fetch-Site")->value() != "same-origin"
&& req->getHeader("Sec-Fetch-Site")->value() != "none")) {
req->send(403, "application/json", "{\"error\":\"origin\"}");
return;
}
WCLock lock(_mux);
uint32_t generation = 0;
// checkAuthLocked alone also accepts initial AP provisioning and legacy
// passwordless nodes. A current explicit login cookie is mandatory here.
if (!checkAuthLocked(req, &generation) || !_session_token[0]
|| _session_generation != generation
|| (uint32_t)(millis() - _session_last_seen) > WEBCONFIG_SESSION_TTL_MS
|| !webConfigCookieMatches(req, _session_token)) {
req->send(401, "application/json", "{\"error\":\"sign in required\"}");
return;
}
// Serialize against setters in other tasks until the response is queued.
// This guard reserves a flag; it does not hold the shared state mutex.
mesh::esp32::managementPassword::ChangeGuard reveal_guard;
if (!reveal_guard.acquired()) {
req->send(503, "application/json", "{\"error\":\"credentials busy\"}");
return;
}
mesh::esp32::managementPassword::Snapshot credential;
if (!mesh::esp32::managementPassword::snapshot(credential)
|| credential.generation != generation || !credential.password[0]) {
req->send(401, "application/json", "{\"error\":\"sign in required\"}");
return;
}
StaticJsonDocument<128> doc;
doc["password"] = credential.password;
const size_t length = measureJson(doc);
AsyncResponseStream* res = doc.overflowed() ? nullptr
: req->beginResponseStream("application/json", length);
bool complete = res && serializeJson(doc, *res) == length
&& res->available() == length
&& res->addHeader("Cache-Control", "no-store")
&& res->addHeader("X-Content-Type-Options", "nosniff");
if (complete) {
const AsyncWebHeader* header = res->getHeader("Cache-Control");
complete = header && header->value() == "no-store";
header = res->getHeader("X-Content-Type-Options");
complete = complete && header && header->value() == "nosniff";
}
doc.clear();
memset(credential.password, 0, sizeof(credential.password));
if (!complete) {
delete res;
req->send(503, "application/json", "{\"error\":\"not enough memory\"}");
return;
}
// A USB/LoRa setter can rotate the committed credential while JSON is
// produced. Do not return it to a session of a different generation.
mesh::esp32::managementPassword::Snapshot latest;
const bool current = mesh::esp32::managementPassword::snapshot(latest)
&& latest.generation == generation;
memset(latest.password, 0, sizeof(latest.password));
if (!current) {
delete res;
clearSessionLocked();
req->send(401, "application/json", "{\"error\":\"sign in required\"}");
return;
}
req->send(res);
}
void WebConfigServer::handleLogout(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
WCLock lock(_mux);
+1
View File
@@ -470,6 +470,7 @@ private:
void handleUi(AsyncWebServerRequest* req);
void handleStatus(AsyncWebServerRequest* req);
void handleLogin(AsyncWebServerRequest* req);
void handleAdminPasswordReveal(AsyncWebServerRequest* req);
void handleLogout(AsyncWebServerRequest* req);
void handleConfigGet(AsyncWebServerRequest* req);
void handleConfigPost(AsyncWebServerRequest* req);
+2
View File
@@ -50,6 +50,8 @@ struct AsyncWebServerRequest {
std::string body, reply; void* _tempObject=nullptr;
int status=0; AsyncWebServerResponse response{};
Client* client() { return has_client ? &socket : nullptr; }
bool hasHeader(const char*) const { return false; }
AsyncWebHeader* getHeader(const char*) { return nullptr; }
void send(int code, const char* =nullptr, const char* content=nullptr) { status=code; reply=content ? content : ""; }
AsyncWebServerResponse* beginResponse(int code, const char*, const char*) { response.status=code; return &response; }
AsyncResponseStream* beginResponseStream(const char*, size_t = 0) { return new AsyncResponseStream; }
+263
View File
@@ -0,0 +1,263 @@
"""Exercise the real emergency telemetry sampler with fake board sensors."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
PREFIX = r'''
#include <helpers/TelemetryHistory.h>
#include <cassert>
#include <cstdint>
#include <cmath>
#include <limits>
using std::isfinite;
struct Clock {
uint32_t time = 1800000000;
uint32_t getCurrentTime() const { return time; }
};
struct Board {
unsigned temperatures = 0, batteries = 0;
float temperature = 21;
uint16_t voltage = 3800;
float getMCUTemperature() { ++temperatures; return temperature; }
uint16_t getBattMilliVolts() { ++batteries; return voltage; }
};
struct CLI {
Board board;
Board* getBoard() { return &board; }
};
struct LocationProvider {
bool isEnabled() { return true; }
bool isValid() { return true; }
long getLatitude() { return 47000000; }
long getLongitude() { return -122000000; }
};
struct SensorManager {
struct VoltageSensorReading { uint8_t channel; float voltage; bool valid; };
unsigned gpsQueries = 0, voltageQueries = 0;
LocationProvider location;
LocationProvider* getLocationProvider() { ++gpsQueries; return &location; }
uint8_t queryVoltageSensors(VoltageSensorReading*, uint8_t) { ++voltageQueries; return 0; }
};
static SensorManager sensors;
namespace mesh {
struct ExternalVoltageHistory {
static constexpr uint8_t MAX_CHANNELS = 4;
struct Reading { uint8_t channel; float voltage; bool valid; };
unsigned records = 0;
void record(uint32_t, const Reading*, uint8_t) { ++records; }
};
}
struct MyMesh {
Clock rtc_clock;
Clock* getRTCClock() { return &rtc_clock; }
CLI _cli;
mesh::TelemetryHistory telemetry_history;
mesh::ExternalVoltageHistory external_voltage_history;
void sampleTelemetryHistory(bool emergency_only = false);
};
'''
MAIN = r'''
int main() {
MyMesh mesh;
constexpr uint32_t interval = mesh::TelemetryHistory::SAMPLE_INTERVAL_SECONDS;
// Start just inside a UTC bucket, as when an emergency period begins.
mesh.rtc_clock.time -= mesh.rtc_clock.time % interval;
mesh.rtc_clock.time += 5;
mesh.sampleTelemetryHistory(true);
assert(mesh._cli.board.temperatures == 1 && mesh._cli.board.batteries == 1);
assert(mesh.telemetry_history.voltageSampleCount() == 1);
assert(sensors.gpsQueries == 0 && sensors.voltageQueries == 0);
assert(mesh.external_voltage_history.records == 0);
// Ten-second maintenance returns do not generate extra measurements.
for (int i = 0; i < 100; ++i) {
mesh.rtc_clock.time += 10;
mesh.sampleTelemetryHistory(true);
}
assert(mesh._cli.board.temperatures == 1 && mesh._cli.board.batteries == 1);
assert(mesh.telemetry_history.voltageSampleCount() == 1);
mesh.rtc_clock.time += 800;
mesh.sampleTelemetryHistory(true);
assert(mesh._cli.board.temperatures == 2 && mesh._cli.board.batteries == 2);
assert(mesh.telemetry_history.voltageSampleCount() == 2);
assert(sensors.gpsQueries == 0 && sensors.voltageQueries == 0);
assert(mesh.external_voltage_history.records == 0);
const uint8_t source[8] = {};
uint8_t snapshot[32] = {};
assert(mesh.telemetry_history.formatTemperatureBinarySnapshot(source, snapshot, sizeof(snapshot)) == 21);
assert(snapshot[19] == 74 && snapshot[20] == 74); // 21 C in the existing wire encoding.
assert(mesh.telemetry_history.formatVoltageBinarySnapshot(source, snapshot, sizeof(snapshot)) == 21);
assert(snapshot[19] == 194 && snapshot[20] == 194); // 3.80 V.
// On returning to normal work the existing per-bucket guard still prevents
// duplicate samples. The next bucket restores ordinary sensor queries.
mesh.sampleTelemetryHistory(false);
assert(mesh._cli.board.temperatures == 2 && sensors.voltageQueries == 0);
mesh.rtc_clock.time += interval;
mesh.sampleTelemetryHistory(false);
assert(mesh._cli.board.temperatures == 3 && mesh._cli.board.batteries == 3);
assert(mesh.telemetry_history.voltageSampleCount() == 3);
assert(sensors.gpsQueries == 1 && sensors.voltageQueries == 1);
assert(mesh.external_voltage_history.records == 1);
// Unsupported/missing board readings remain missing, not invented values.
mesh.rtc_clock.time += interval;
mesh._cli.board.temperature = std::numeric_limits<float>::quiet_NaN();
mesh._cli.board.voltage = 0;
mesh.sampleTelemetryHistory(true);
assert(mesh.telemetry_history.formatTemperatureBinarySnapshot(source, snapshot, sizeof(snapshot)) == 23);
assert(snapshot[22] == 0);
assert(mesh.telemetry_history.formatVoltageBinarySnapshot(source, snapshot, sizeof(snapshot)) == 23);
assert(snapshot[22] == 0);
}
'''
GATE_PREFIX = r'''
#include <helpers/EmergencyPowerRuntime.h>
#include <cassert>
using namespace mesh::wireless;
void delay(uint32_t) {}
struct BackendStub : Backend {
uint8_t active = All;
uint8_t available() const override { return All; }
uint8_t enabled() const override { return active; }
uint8_t clients() const override { return Independent; }
Result set(uint8_t bit, bool on) override {
if (on) active |= bit; else active &= ~bit;
return Result::Done;
}
};
struct CLI {
uint32_t budget = 1800000;
bool active = true, radioSleeping = false;
uint32_t emergencySleepMilliseconds() const { return budget; }
bool emergencyActive() const { return active; }
void setEmergencyRadioSleeping(bool value) { radioSleeping = value; }
};
struct Mesh {
bool available = true, outbound = false;
int samples = 0, normal = 0;
CLI cli;
bool hasOutbound() const { return outbound; }
bool emergencyPowerBusy() const { return false; }
CLI& getPowerCLI() { return cli; }
void sampleEmergencyTelemetry() { assert(!available && cli.radioSleeping); ++samples; }
protected:
friend class mesh::power::EmergencyPowerRuntime;
bool isRadioAvailable() const { return available; }
void setRadioAvailable(bool value) { available = value; }
};
struct Radio {
bool sleeping = false;
bool isMorseTxActive() const { return false; }
bool isMorseRxActive() const { return false; }
bool setTrackerSleep(bool value) { sleeping = value; return true; }
bool recoverRadio(bool) { return true; }
};
struct Board {
int sleeps = 0;
bool supportsEmergencySleep() const { return true; }
bool isOTAUpdateRunning() const { return false; }
bool isRadioTestActive() const { return false; }
void setEmergencyRadioSleeping(bool) {}
bool emergencySleepMilliseconds(uint32_t budget) { assert(budget <= 10000); ++sleeps; return true; }
};
struct Fixture {
BackendStub wireless;
Mesh the_mesh;
Radio radio_driver;
Board board;
mesh::power::EmergencyPowerRuntime emergency_power;
bool emergency_busy = false;
Fixture() { control().begin(wireless); }
void loop();
};
'''
GATE_MAIN = r'''
int main() {
{
Fixture f;
f.loop();
assert(f.board.sleeps == 1 && f.radio_driver.sleeping && !f.the_mesh.available);
assert(f.the_mesh.samples == 1 && f.the_mesh.normal == 0 && f.wireless.active == 0);
f.loop();
assert(f.board.sleeps == 2 && f.the_mesh.samples == 2);
f.the_mesh.cli.budget = 0;
f.loop();
assert(f.the_mesh.available && !f.radio_driver.sleeping && f.the_mesh.normal == 1);
assert(f.the_mesh.samples == 2 && f.wireless.active == All);
}
{
// A standalone schedule sleeps but must not take emergency samples.
Fixture f;
f.the_mesh.cli.active = false;
f.loop();
assert(f.board.sleeps == 1 && !f.the_mesh.available && f.radio_driver.sleeping);
assert(f.the_mesh.samples == 0 && f.the_mesh.normal == 0);
}
{
// Live RF work prevents entry and proceeds through the normal loop.
Fixture f;
f.the_mesh.outbound = true;
f.loop();
assert(f.board.sleeps == 0 && f.the_mesh.available && !f.radio_driver.sleeping);
assert(f.the_mesh.samples == 0 && f.the_mesh.normal == 1);
}
}
'''
class EmergencyTelemetrySamplingTests(unittest.TestCase):
def test_real_role_samplers_record_once_per_bucket_without_optional_sensors(self):
compiler = shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++17 compiler is required")
for role in ("simple_repeater", "simple_room_server"):
with self.subTest(role=role), tempfile.TemporaryDirectory() as folder:
work = Path(folder)
source = (ROOT / "examples" / role / "MyMesh.cpp").read_text(encoding="utf-8")
actual = extract_braced(source, "void MyMesh::sampleTelemetryHistory(")
(work / "main.cpp").write_text(PREFIX + actual + MAIN, encoding="ascii")
flags = [] if os.name == "nt" else ["-fsanitize=address,undefined",
"-fno-sanitize-recover=all", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
exe = work / "check"
build = subprocess.run([compiler, "-std=c++17", "-O1", "-Wall", "-Wextra",
"-Werror", "-DENV_INCLUDE_GPS=1", *flags, "-I", str(ROOT / "src"),
str(work / "main.cpp"), "-o", str(exe)], capture_output=True, text=True)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
result = subprocess.run([str(exe)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_actual_role_loop_gate_keeps_radio_asleep_and_respects_schedule(self):
compiler = shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++17 compiler is required")
for role in ("simple_repeater", "simple_room_server"):
with self.subTest(role=role), tempfile.TemporaryDirectory() as folder:
work = Path(folder)
source = (ROOT / "examples" / role / "main.cpp").read_text(encoding="utf-8")
gate = extract_braced(source, "if (emergency_power.service(")
code = GATE_PREFIX + "\nvoid Fixture::loop() {\n" + gate + "\n++the_mesh.normal;\n}\n" + GATE_MAIN
(work / "Arduino.h").write_text("#pragma once\n#include <stdint.h>\nvoid delay(uint32_t);\n")
(work / "main.cpp").write_text(code, encoding="ascii")
flags = [] if os.name == "nt" else ["-fsanitize=address,undefined",
"-fno-sanitize-recover=all", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
exe = work / "check"
build = subprocess.run([compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror",
"-DMESH_ENABLE_EMERGENCY_POWER=1", *flags, "-I", str(work), "-I", str(ROOT / "src"),
str(work / "main.cpp"), "-o", str(exe)], capture_output=True, text=True)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
result = subprocess.run([str(exe)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -144,7 +144,7 @@ public:
void configureHistory() {
@HISTORY_INIT@
}
void sampleTelemetryHistory();
void sampleTelemetryHistory(bool emergency_only = false);
bool handleTelemetryHistoryCommand(const char*,char*,size_t=160,bool=true);
int handleRequest(ClientInfo*,uint32_t,uint8_t*,size_t,size_t);
void handleCommand(uint32_t sender_timestamp,char* command,char* reply,
@@ -391,6 +391,7 @@ static Store* selected=nullptr;static bool actual_history=false;
struct Board {void loop() {++board_loops;order.push_back(3);}} board;
struct Radio {} radio_driver;
namespace mesh {
struct RadioProfileCLI {static void configureBootTuning(Store*,Radio&) {}};
namespace leds {void beginSettings(Board&,Store*) {}}
namespace power {
bool beginRegulatorSettings(Board&,Radio&,Store*) {++reg_begin;return true;}
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Exercise WebConfig's actual password batch through the durable prefs image."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
import test_common_prefs_uncertain_reply as prefs
import test_webconfig_password_sessions as sessions
ROOT = Path(__file__).resolve().parents[1]
class WebConfigPasswordPersistenceTest(unittest.TestCase):
def test_form_save_survives_reboot_on_observer_and_standard_builds(self):
# Combine the production WebConfig form/parser and drain with the
# production CommonCLI serializer, transaction and reboot image reader.
# Only the filesystem and HTTP transport are host adapters.
common = prefs.actual_harness().split('// Included after the extracted')[0]
web = sessions.WebConfigPasswordSessionsTest().command_fixture()
web = web[web.index('struct AsyncResponseStream'):web.index('void reset()')]
source = r'''
#include <mutex>
#include <string>
#include <ArduinoJson.h>
#include <helpers/esp32/ManagementPassword.h>
#include <helpers/WebConfigBatch.h>
#include <helpers/WebConfigKeys.h>
#ifdef WITH_MQTT_BRIDGE
const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = {};
#endif
namespace password = mesh::esp32::managementPassword;
using String = std::string;
using WCLock = std::lock_guard<std::mutex>;
uint32_t millis() { return 1000; }
namespace mesh {
namespace wifi {
constexpr bool kPrimaryEspNowRadio = false;
bool parseEspNowChannel(const char*, uint8_t&) { return false; }
}
struct Debug { template<typename... T> void printf(const char*, T...) {} };
Debug& usbDebugPort() { static Debug value; return value; }
}
''' + common + r'''
struct Callback {
unsigned starts = 0, ends = 0, calls = 0;
CommonCLI infrastructure;
bool supportsCliTerminal() const { return true; }
void execCommand(char* cmd, char* reply) { ++calls; infrastructure.password(cmd, reply); }
void execAdminCommand(char* cmd, char* reply) { execCommand(cmd, reply); }
void onConfigBatchStart() { ++starts; }
void onConfigBatchEnd() { ++ends; }
};
''' + web + r'''
int main() {
WebConfigServer server;
strcpy(server.cb.infrastructure.prefs.password, "factory");
server.cb.infrastructure.savePrefs(&server.cb.infrastructure.fs, PrefsSaveRouting::Scope::Common);
assert(server.cb.infrastructure._common_save_succeeded);
assert(password::initialize("factory"));
AsyncWebServerRequest req;
req.payload("{\"set\":{\"password\":\"new-secret\"}}");
server.configForTest(&req);
assert(req.status == 202);
server.drainBatch(1000);
assert(server.cb.calls == 1 && !strcmp(server._batch[0].reply, "OK"));
assert(server.cb.infrastructure._common_save_succeeded);
password::Snapshot active; assert(password::snapshot(active));
assert(!strcmp(active.password, "new-secret"));
CommonCLI reboot;
reboot.fs.files = server.cb.infrastructure.fs.files;
strcpy(reboot.prefs.password, "factory");
reboot.loadPrefsInt(&reboot.fs, "/com_prefs");
assert(!strcmp(reboot.prefs.password, "new-secret"));
auto& state = password::detail::state();
{ password::detail::Lock lock(state); state.current = {}; state.changing = state.uploading = state.rotating = false; }
assert(password::initialize(reboot.prefs.password));
assert(password::snapshot(active) && !strcmp(active.password, "new-secret"));
assert(password::basicAuthorizationMatches("Basic YWRtaW46bmV3LXNlY3JldA==", active.password));
assert(!password::basicAuthorizationMatches("Basic YWRtaW46ZmFjdG9yeQ==", active.password));
// A failed durable save must not report success, publish the candidate,
// or change the credential recovered by a fresh boot.
{ password::detail::Lock lock(state); state.current = {}; state.changing = state.uploading = state.rotating = false; }
assert(password::initialize("factory"));
WebConfigServer failed;
strcpy(failed.cb.infrastructure.prefs.password, "factory");
failed.cb.infrastructure.savePrefs(&failed.cb.infrastructure.fs, PrefsSaveRouting::Scope::Common);
assert(failed.cb.infrastructure._common_save_succeeded);
failed.cb.infrastructure.fs.fail_write = true;
AsyncWebServerRequest fail_req;
fail_req.payload("{\"set\":{\"password\":\"new-secret\"}}");
failed.configForTest(&fail_req);
assert(fail_req.status == 202);
failed.drainBatch(1000);
assert(strstr(failed._batch[0].reply, "unconfirmed") != nullptr);
assert(password::snapshot(active) && !strcmp(active.password, "factory"));
assert(!strcmp(failed.cb.infrastructure.prefs.password, "factory"));
CommonCLI failed_reboot;
failed_reboot.fs.files = failed.cb.infrastructure.fs.files;
failed_reboot.loadPrefsInt(&failed_reboot.fs, "/com_prefs");
assert(!strcmp(failed_reboot.prefs.password, "factory"));
}
'''
mock = (ROOT / 'test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h').read_text()
transaction = (ROOT / 'src/helpers/ContactFileTransaction.h').read_text()
json_header = next((ROOT / '.pio/libdeps').glob('*/ArduinoJson/src/ArduinoJson.h'), None)
self.assertIsNotNone(json_header, 'ArduinoJson dependency headers are required')
with tempfile.TemporaryDirectory(prefix='webconfig-password-persistence-') as directory:
work = Path(directory)
(work / 'helpers').mkdir()
(work / 'Arduino.h').write_text('#pragma once\n#define PROGMEM\n')
(work / 'helpers/IdentityStore.h').write_text(mock)
(work / 'ContactFileTransaction.h').write_text(transaction.replace(
'#include "IdentityStore.h"', '#include <helpers/IdentityStore.h>'))
(work / 'test.cpp').write_text(source)
sanitizer = [] if os.name == 'nt' else ['-fsanitize=address,undefined',
'-fno-sanitize-recover=all', '-fno-pie', '-no-pie']
for observer in (False, True):
with self.subTest(observer=observer):
binary = work / ('test.exe' if os.name == 'nt' else 'test')
command = [os.environ.get('CXX', 'g++'), '-std=c++17',
'-DESP_PLATFORM=1', '-DESP32_PLATFORM=1',
'-DMESH_MANAGEMENT_PASSWORD_HOST_TEST=1', '-DENABLE_OTA=1',
*(['-DWITH_MQTT_BRIDGE=1'] if observer else []), *sanitizer,
'-I' + str(work), '-I' + str(ROOT / 'src'),
'-I' + str(ROOT / 'src/helpers'), '-I' + str(json_header.parent),
str(work / 'test.cpp'), '-o', str(binary)]
built = subprocess.run(command, capture_output=True, text=True, timeout=90)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == '__main__':
unittest.main()
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Exercise the real password reveal, authentication and rotation gates."""
from pathlib import Path
import unittest
import test_webconfig_recovery_auth as auth
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
class WebConfigPasswordRevealTest(unittest.TestCase):
def test_explicit_session_origin_expiry_and_rotation(self):
cpp = (ROOT / 'src/helpers/esp32/WebConfigServer.cpp').read_text()
fixture = auth.WebConfigRecoveryAuthTest().fixture()
fixture = fixture[:fixture.index('int main() {')]
fixture = fixture.replace(' Header header; std::string body, reply; void* _tempObject=nullptr;',
' Header header; std::map<std::string, Header> headers; std::string body, reply; void* _tempObject=nullptr;')
fixture = fixture.replace('struct AsyncWebServerRequest {',
'static bool rotate_on_cookie = false;\nstruct AsyncWebServerRequest {')
fixture = fixture.replace(' bool hasHeader(const char*) const { return !header.cookies.empty(); }\n'
' Header* getHeader(const char*) { return &header; }',
' bool hasHeader(const char* name) const { return !strcmp(name,"Cookie") ? !header.cookies.empty() : headers.count(name); }\n'
' Header* getHeader(const char* name) { if (!strcmp(name,"Cookie") && rotate_on_cookie) { rotate_on_cookie=false; commitCredential("rotated-secret"); } return !strcmp(name,"Cookie") ? &header : &headers.at(name); }')
fixture = fixture.replace(' void handleLogin(AsyncWebServerRequest*);',
' void handleLogin(AsyncWebServerRequest*);\n void handleAdminPasswordReveal(AsyncWebServerRequest*);')
fixture = fixture.replace('struct AsyncWebServerResponse {',
'static bool corrupt_cache_header = false;\nstruct AsyncWebServerResponse {')
fixture = fixture.replace('bool addHeader(const char* key, const char* value) { headers[key]=value; return true; }',
'bool addHeader(const char* key, const char* value) { headers[key]=(corrupt_cache_header && !strcmp(key,"Cache-Control")) ? "broken" : value; return true; }')
# Force a real external credential rotation during response production.
fixture = fixture.replace('struct AsyncResponseStream : AsyncWebServerResponse {',
'static bool rotate_during_reveal = false, rotation_blocked = false;\nstruct AsyncResponseStream : AsyncWebServerResponse {')
fixture = fixture.replace('size_t write(uint8_t value) { body.push_back(char(value)); return 1; }',
'size_t write(uint8_t value) { if (rotate_during_reveal) { rotate_during_reveal=false; password::ChangeGuard change; rotation_blocked=!change.acquired(); if (change.acquired()) change.publish("rotated-secret"); } body.push_back(char(value)); return 1; }')
fixture = fixture.replace('size_t write(const uint8_t* bytes, size_t count) { body.append(reinterpret_cast<const char*>(bytes), count); return count; }',
'size_t write(const uint8_t* bytes, size_t count) { if (rotate_during_reveal) { rotate_during_reveal=false; password::ChangeGuard change; rotation_blocked=!change.acquired(); if (change.acquired()) change.publish("rotated-secret"); } body.append(reinterpret_cast<const char*>(bytes), count); return count; }')
fixture += '\n' + extract_braced(cpp, 'void WebConfigServer::handleAdminPasswordReveal(')
fixture += r'''
static void revealHeaders(AsyncWebServerRequest& req) {
req.headers["X-WebConfig-Reveal"].cookies = "1";
req.headers["Host"].cookies = "192.168.4.1";
req.headers["Origin"].cookies = "http://192.168.4.1";
req.headers["Sec-Fetch-Site"].cookies = "same-origin";
}
static void useCookie(AsyncWebServerRequest& req, const WebConfigServer& portal) {
req.header.cookies = std::string("wcs=") + portal._session_token;
}
int main() {
WebConfigServer portal;
portal._mode = WebConfigServer::MODE_LAN;
AsyncWebServerRequest login; login.password("existing-secret");
portal.handleLogin(&login); assert(login.status == 200 && portal._session_token[0]);
AsyncWebServerRequest empty; portal.handleAdminPasswordReveal(&empty); assert(empty.status == 403 && empty.reply.empty());
revealHeaders(empty); portal.handleAdminPasswordReveal(&empty); assert(empty.status == 401 && empty.reply.empty());
useCookie(empty, portal); portal.handleAdminPasswordReveal(&empty);
assert(empty.status == 200 && empty.reply == "{\"password\":\"existing-secret\"}");
assert(empty.response.headers["Cache-Control"] == "no-store");
assert(empty.response.headers["X-Content-Type-Options"] == "nosniff");
AsyncWebServerRequest cache_failure; revealHeaders(cache_failure); useCookie(cache_failure, portal);
corrupt_cache_header = true; portal.handleAdminPasswordReveal(&cache_failure); corrupt_cache_header = false;
assert(cache_failure.status == 503 && cache_failure.reply.empty());
{
password::ChangeGuard concurrent; assert(concurrent.acquired());
AsyncWebServerRequest busy; revealHeaders(busy); useCookie(busy, portal);
portal.handleAdminPasswordReveal(&busy); assert(busy.status == 503 && busy.reply.empty());
}
AsyncWebServerRequest cross; revealHeaders(cross); useCookie(cross, portal);
cross.headers["Origin"].cookies = "http://attacker.invalid";
portal.handleAdminPasswordReveal(&cross); assert(cross.status == 403 && cross.reply.empty());
cross.headers["Origin"].cookies = "http://192.168.4.1";
cross.headers["Sec-Fetch-Site"].cookies = "cross-site";
portal.handleAdminPasswordReveal(&cross); assert(cross.status == 403 && cross.reply.empty());
now_ms += WEBCONFIG_SESSION_TTL_MS + 1;
AsyncWebServerRequest expired; revealHeaders(expired); useCookie(expired, portal);
portal.handleAdminPasswordReveal(&expired); assert(expired.status == 401 && expired.reply.empty());
// AP association allows provisioning, never access to the saved secret.
WebConfigServer setup; setup._mode = WebConfigServer::MODE_SETUP;
setup._setup_provisioning_privilege = true;
password::Snapshot snapshot; assert(password::snapshot(snapshot));
setup._ap_auth_generation = snapshot.generation;
AsyncWebServerRequest ap; revealHeaders(ap);
assert(setup.checkAuth(&ap)); setup.handleAdminPasswordReveal(&ap);
assert(ap.status == 401 && ap.reply.empty());
// Existing implicit-setup login remains unchanged; explicit login must prove
// the password and create a real session.
AsyncWebServerRequest implicit; implicit.password("wrong"); setup.handleLogin(&implicit);
assert(implicit.status == 200 && !setup._session_token[0]);
AsyncWebServerRequest explicit_login; explicit_login.password("wrong");
explicit_login.headers["X-WebConfig-Explicit-Login"].cookies = "1";
setup.handleLogin(&explicit_login); assert(explicit_login.status == 401 && !setup._session_token[0]);
explicit_login.password("existing-secret"); setup.handleLogin(&explicit_login);
assert(explicit_login.status == 200 && setup._session_token[0]);
useCookie(ap, setup); setup.handleAdminPasswordReveal(&ap);
assert(ap.status == 200 && ap.reply.find("existing-secret") != std::string::npos);
AsyncWebServerRequest foreign; revealHeaders(foreign); useCookie(foreign, setup);
foreign.socket.local_ip=9; setup.handleAdminPasswordReveal(&foreign);
assert(foreign.status == 401 && foreign.reply.empty());
// Forced explicit login still uses the normal per-client throttle.
WebConfigServer throttled; throttled._mode=WebConfigServer::MODE_SETUP;
throttled._setup_provisioning_privilege=true;
assert(password::snapshot(snapshot)); throttled._ap_auth_generation=snapshot.generation;
AsyncWebServerRequest guessing; guessing.socket.remote=42;
guessing.headers["X-WebConfig-Explicit-Login"].cookies="1";
for (int i=0; i<5; ++i) { guessing.password("wrong"); throttled.handleLogin(&guessing); assert(guessing.status==401); }
guessing.password("existing-secret"); throttled.handleLogin(&guessing);
assert(guessing.status==429 && !throttled._session_token[0]);
// A setter outside WebConfig cannot change the credential while the
// authorized response is produced.
AsyncWebServerRequest race; revealHeaders(race); useCookie(race, setup);
rotate_during_reveal = true; setup.handleAdminPasswordReveal(&race);
assert(race.status == 200 && rotation_blocked && setup._session_token[0]);
// A rotation between authentication and guard acquisition is detected.
AsyncWebServerRequest pre_guard; revealHeaders(pre_guard); useCookie(pre_guard, setup);
rotate_on_cookie = true; setup.handleAdminPasswordReveal(&pre_guard);
assert(pre_guard.status == 401 && pre_guard.reply.empty());
// JSON must escape arbitrary supported password bytes.
WebConfigServer escaped; escaped._mode=WebConfigServer::MODE_LAN;
resetCredential("a\"b\\cdef"); AsyncWebServerRequest escaped_login;
escaped_login.body = "{\"password\":\"a\\\"b\\\\cdef\"}";
escaped_login._tempObject = escaped_login.body.data();
escaped.handleLogin(&escaped_login); assert(escaped_login.status == 200);
AsyncWebServerRequest escaped_req; revealHeaders(escaped_req); useCookie(escaped_req, escaped);
escaped.handleAdminPasswordReveal(&escaped_req);
assert(escaped_req.status == 200);
DynamicJsonDocument json(128); assert(!deserializeJson(json, escaped_req.reply));
assert(!strcmp(json["password"], "a\"b\\cdef"));
// Legacy passwordless auto-access cannot mint an authenticated secret view.
WebConfigServer passwordless; passwordless._mode=WebConfigServer::MODE_LAN;
resetCredential(""); AsyncWebServerRequest blank_login;
blank_login.headers["X-WebConfig-Explicit-Login"].cookies="1";
blank_login.password(""); passwordless.handleLogin(&blank_login);
assert(blank_login.status==200 && !passwordless._session_token[0]);
AsyncWebServerRequest blank_view; revealHeaders(blank_view);
passwordless.handleAdminPasswordReveal(&blank_view);
assert(blank_view.status==401 && blank_view.reply.empty());
}
'''
auth.WebConfigRecoveryAuthTest().compile_run(fixture)
if __name__ == '__main__':
unittest.main()
+2 -1
View File
@@ -15,7 +15,8 @@ class WebConfigUiTest(unittest.TestCase):
def test_wifi_password_fields_have_local_show_controls(self):
page = SOURCE.read_text(encoding="utf-8")
self.assertEqual(page.count('onclick="toggleWiFiPassword(this)"'), 2)
self.assertEqual(page.count('class="pwrow"'), 2)
self.assertEqual(len(re.findall(
r'<div class="pwrow">\s*<input type="password" data-k="wifi\.pwd"', page)), 2)
self.assertIn('type="password" data-k="wifi.pwd" id="wz-wifi-pwd"', page)
self.assertIn('type="password" data-k="wifi.pwd" id="app-wifi-pwd"', page)
self.assertIn('input.type=show?"text":"password"', page)
+95
View File
@@ -77,6 +77,101 @@ def embedded_page():
class WebConfigUiRuntimeTest(unittest.TestCase):
def test_admin_eye_reveals_on_demand_without_changing_saved_settings(self):
status, config = self.setup_values()
status.update(mode="lan", needs_password=False)
prelude = r'''<script>
(function(){
var status=%s,config=%s,reveals=0,header="",submitted=null;
function response(v){return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(v)}})}
window.fetch=function(path,opts){
if(path==="/api/status")return response(status);
if(path==="/api/admin-password/reveal"){reveals++;header=(opts.headers||{})["X-WebConfig-Reveal"];return response({password:"saved-test"})}
if(path==="/api/config"&&opts&&opts.method==="POST"){submitted=JSON.parse(opts.body);return response({state:"pending"})}
if(path.indexOf("/api/config/result?")===0)return response({state:"running",results:[]});
if(path==="/api/config")return response(config);
return response({});
};
window.addEventListener("load",function(){setTimeout(function(){
var body=document.body,input=document.getElementById("app-admin-password"),btn=document.querySelector('[aria-controls="app-admin-password"]');
body.setAttribute("data-prefetch",reveals);body.setAttribute("data-placeholder",input.placeholder);
btn.click();setTimeout(function(){
body.setAttribute("data-revealed",input.type==="text"&&input.value==="saved-test");
body.setAttribute("data-header",header);body.setAttribute("data-clean",!("password" in st.dirty)&&st.orig.password==="");
st.dirty.name="Changed";saveChanges();
body.setAttribute("data-post-clean",!!submitted&&!("password" in submitted.set));
btn.click();body.setAttribute("data-hidden",input.type==="password"&&input.value===""&&!("password" in st.dirty));
input.value="new-test";input.dispatchEvent(new Event("input",{bubbles:true}));btn.click();
body.setAttribute("data-typed",input.type==="text"&&input.value==="new-test"&&st.dirty.password==="new-test"&&reveals===1);
},20);
},200)});
})();
</script>''' % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude, virtual_time=900)
for attr, expected in (("prefetch", "0"), ("placeholder", "********"),
("revealed", "true"), ("header", "1"),
("clean", "true"), ("post-clean", "true"),
("hidden", "true"), ("typed", "true")):
self.assertIn(f'data-{attr}="{expected}"', dom)
def test_admin_eye_drops_late_reply_after_logout(self):
status, config = self.setup_values()
status.update(mode="lan", needs_password=False)
prelude = r'''<script>
(function(){
var status=%s,config=%s,resolveReveal;
function response(v){return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(v)}})}
window.fetch=function(path){
if(path==="/api/status")return response(status);
if(path==="/api/config")return response(config);
if(path==="/api/admin-password/reveal")return new Promise(function(r){resolveReveal=r});
return response({});
};
window.addEventListener("load",function(){setTimeout(function(){
var input=document.getElementById("app-admin-password"),btn=document.querySelector('[aria-controls="app-admin-password"]');
btn.click();doLogout();setTimeout(function(){
resolveReveal({ok:true,status:200,json:function(){return Promise.resolve({password:"saved-test"})}});
setTimeout(function(){document.body.setAttribute("data-late-safe",input.value===""&&input.type==="password"&&!input.dataset.savedReveal&&!st.authed)},20);
},20);
},200)});
})();
</script>''' % (json.dumps(status), json.dumps(config))
self.assertIn('data-late-safe="true"', self.run_page(prelude, virtual_time=900))
def test_admin_reveal_requires_explicit_setup_login(self):
status, config = self.setup_values()
status.update(mode="setup", needs_password=False)
prelude = r'''<script>
(function(){
var status=%s,config=%s,authorized=false,loginHeader="",reveals=0;
function response(v,code){return Promise.resolve({ok:code<400,status:code,json:function(){return Promise.resolve(v)}})}
window.fetch=function(path,opts){
if(path==="/api/status")return response(status,200);
if(path==="/api/config")return response(config,200);
if(path==="/api/admin-password/reveal"){reveals++;return response(authorized?{password:"saved-test"}:{error:"auth"},authorized?200:401)}
if(path==="/api/login"){loginHeader=(opts.headers||{})["X-WebConfig-Explicit-Login"];authorized=true;return response({ok:true},200)}
return response({},200);
};
window.addEventListener("load",function(){setTimeout(function(){
var input=document.getElementById("wz-admin-password"),btn=document.querySelector('[aria-controls="wz-admin-password"]');
btn.click();setTimeout(function(){
document.body.setAttribute("data-login-required",!document.getElementById("v-login").classList.contains("hide"));
document.body.setAttribute("data-login-guidance",document.getElementById("login-note").textContent.indexOf("tap the eye again")>=0);
document.getElementById("login-pwd").value="saved-test";
document.getElementById("login-form").dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}));
setTimeout(function(){btn.click();setTimeout(function(){
document.body.setAttribute("data-explicit-login",loginHeader);
document.body.setAttribute("data-after-login",reveals===2&&input.value==="saved-test"&&input.type==="text");
},20)},80);
},30);
},200)});
})();
</script>''' % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude, virtual_time=1200)
for attr, expected in (("login-required", "true"), ("login-guidance", "true"),
("explicit-login", "1"), ("after-login", "true")):
self.assertIn(f'data-{attr}="{expected}"', dom)
def test_tx_save_receipt_confirms_durable_save_and_automatic_application(self):
for reply, chip, toast in (("OK - saved; pending", "Saved", "Saved; radio changes apply automatically"),
("OK", "OK", "Saved [OK]")):
+74 -13
View File
@@ -61,6 +61,8 @@ input.dirty,select.dirty{border-color:var(--warn)}
.pwrow{display:flex;align-items:stretch;gap:8px}
.pwrow input{flex:1;min-width:0}
.pwrow .pwshow{min-width:62px}
.pwrow .admin-eye{min-width:44px;display:flex;align-items:center;justify-content:center}
.admin-eye svg{width:20px;height:20px;stroke:currentColor;fill:none;stroke-width:1.7}
.sw{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 0}
.sw span b{display:block;font-size:13.5px;font-weight:600}
.sw span i{font-style:normal;font-size:11.5px;color:var(--mut)}
@@ -289,13 +291,15 @@ body.tab-cli{padding-bottom:0}
</div>
<div class="card password-card">
<h2>Admin password</h2>
<p style="font-size:13.5px;color:var(--mut);margin-bottom:12px">One admin password for this node's configuration page, firmware uploads, and protected setup WiFi.</p>
<div class="f"><label>New password</label>
<input type="password" data-k="password" maxlength="15" autocomplete="new-password">
<p style="font-size:13.5px;color:var(--mut);margin-bottom:12px">One admin password for this node's configuration page, firmware uploads, and protected setup WiFi. Tap the eye to view the saved password after signing in.</p>
<div class="f"><label for="wz-admin-password">Admin password</label>
<div class="pwrow"><input type="password" id="wz-admin-password" data-k="password" maxlength="15" autocomplete="new-password">
<button class="btn sec sm admin-eye" type="button" aria-label="Show admin password" aria-pressed="false" aria-controls="wz-admin-password" data-label="admin password" onclick="toggleAdminPassword(this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M2 12s3.6-6 10-6 10 6 10 6-3.6 6-10 6S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg></button></div>
<div class="hint">Maximum 15 UTF-8 bytes. Use 8-15 printable ASCII characters to also protect the setup WiFi.</div>
<div class="hint hide" data-admin-ap-hint></div></div>
<div class="f"><label>Confirm password</label>
<input type="password" data-cfm="password" maxlength="15" autocomplete="new-password"></div>
<div class="f"><label for="wz-admin-confirm">Confirm new password</label>
<div class="pwrow"><input type="password" id="wz-admin-confirm" data-cfm="password" maxlength="15" autocomplete="new-password">
<button class="btn sec sm admin-eye" type="button" aria-label="Show password confirmation" aria-pressed="false" aria-controls="wz-admin-confirm" data-label="password confirmation" onclick="toggleAdminPassword(this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M2 12s3.6-6 10-6 10 6 10 6-3.6 6-10 6S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg></button></div></div>
</div>
<div class="row">
<button class="btn sec" onclick="wzGo(1)">Back</button>
@@ -378,12 +382,14 @@ body.tab-cli{padding-bottom:0}
</div>
<div class="card password-card">
<h2>Admin password</h2>
<div class="note">Used for this node's configuration page and firmware uploads. Leave blank to keep the current password. Maximum 15 UTF-8 bytes. Use 8-15 printable ASCII characters to also protect the setup WiFi with this password.</div>
<div class="f"><label>New password</label>
<input type="password" data-k="password" maxlength="15" autocomplete="new-password">
<div class="note">Used for this node's configuration page and firmware uploads. Tap the eye to view the saved password after signing in; type a new one to change it. Leave blank to keep it. Maximum 15 UTF-8 bytes. Use 8-15 printable ASCII characters to also protect the setup WiFi with this password.</div>
<div class="f"><label for="app-admin-password">Admin password</label>
<div class="pwrow"><input type="password" id="app-admin-password" data-k="password" maxlength="15" autocomplete="new-password">
<button class="btn sec sm admin-eye" type="button" aria-label="Show admin password" aria-pressed="false" aria-controls="app-admin-password" data-label="admin password" onclick="toggleAdminPassword(this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M2 12s3.6-6 10-6 10 6 10 6-3.6 6-10 6S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg></button></div>
<div class="hint hide" data-admin-ap-hint></div></div>
<div class="f"><label>Confirm password</label>
<input type="password" data-cfm="password" maxlength="15" autocomplete="new-password"></div>
<div class="f"><label for="app-admin-confirm">Confirm new password</label>
<div class="pwrow"><input type="password" id="app-admin-confirm" data-cfm="password" maxlength="15" autocomplete="new-password">
<button class="btn sec sm admin-eye" type="button" aria-label="Show password confirmation" aria-pressed="false" aria-controls="app-admin-confirm" data-label="password confirmation" onclick="toggleAdminPassword(this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M2 12s3.6-6 10-6 10 6 10 6-3.6 6-10 6S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg></button></div></div>
</div>
<div class="card">
<h2>LoRa radio <span class="chip err hide" id="radio-warn">reboot to apply</span></h2>
@@ -692,6 +698,54 @@ function toggleWiFiPassword(btn){
btn.setAttribute("aria-label",(show?"Hide":"Show")+" WiFi password");
input.focus();
}
// Revealed saved credentials live only in the requesting input until hidden,
// edited, or the session/view changes. They never enter orig or dirty state.
var adminRevealEpoch=0;
function adminEyeState(btn,input,show){
input.type=show?"text":"password";
btn.setAttribute("aria-pressed",show?"true":"false");
btn.setAttribute("aria-label",(show?"Hide ":"Show ")+btn.dataset.label);
}
function clearAdminReveals(except){
adminRevealEpoch++;
$$(".admin-eye").forEach(function(btn){
var input=document.getElementById(btn.getAttribute("aria-controls"));
if(!input)return;
if(input.dataset.savedReveal){
delete input.dataset.savedReveal;
if(input!==except)input.value="";
}
if(input!==except)adminEyeState(btn,input,false);
});
}
function toggleAdminPassword(btn){
var input=document.getElementById(btn.getAttribute("aria-controls"));
if(!input)return;
if(input.type==="text"){
adminRevealEpoch++;
if(input.dataset.savedReveal){input.value="";delete input.dataset.savedReveal}
adminEyeState(btn,input,false);return;
}
// A typed replacement or confirmation is already in the form. Only an
// empty, untouched new-password field can request the saved credential.
if(input.value||input.dataset.cfm||("password" in st.dirty)||!st.passwordSupported){
adminEyeState(btn,input,true);return;
}
if(!st.authed){toast("Sign in to view the saved password");return}
var request=++adminRevealEpoch,authEpoch=loginEpoch;
btn.disabled=true;
api("/api/admin-password/reveal",{headers:{"X-WebConfig-Reveal":"1"}}).then(function(result){
if(request!==adminRevealEpoch||authEpoch!==loginEpoch||!st.authed||input.value||("password" in st.dirty))return;
if(!result||typeof result.password!=="string"||!result.password)throw Error("unavailable");
input.value=result.password;input.dataset.savedReveal="1";
adminEyeState(btn,input,true);
}).catch(function(error){
if(error.message==="auth"&&!error.staleAuth&&!st.authed){
$("#login-note").textContent="Sign in, then tap the eye again to view the saved password.";
$("#login-note").classList.remove("hide");
}else if(request===adminRevealEpoch)toast("Couldn't show the saved password");
}).then(function(){btn.disabled=false});
}
function adminApEligible(password){return /^[\x20-\x7e]{8,15}$/.test(password)}
function updateSetupSecurity(){
var setup=st.mode==="setup",banner=$("#setup-security");
@@ -776,6 +830,7 @@ function applyFeatures(){
var floodCard=$("#radio-flood-card");
if(floodCard)floodCard.classList.toggle("hide",!(st.caps&(128|256)));
$$(".password-card").forEach(function(el){el.classList.toggle("hide",!st.passwordSupported)});
$$("[data-k='password']").forEach(function(el){el.placeholder=st.passwordSupported?"********":""});
$$('[data-k="wifi.pwd"]').forEach(function(el){el.maxLength=st.wifiPsk64?64:63});
$$('[data-wifi-pwd-hint]').forEach(function(el){
el.textContent=st.wifiPsk64
@@ -844,10 +899,11 @@ function boot(){
});
}
function show(v){["#v-login","#v-wizard","#v-app"].forEach(function(id){$(id).classList.add("hide")});$(v).classList.remove("hide");
function show(v){clearAdminReveals();["#v-login","#v-wizard","#v-app"].forEach(function(id){$(id).classList.add("hide")});$(v).classList.remove("hide");
document.body.classList.remove("tab-cli"); // re-armed by the tab click when the CLI is the one shown
updateSaveBar()}
function showLogin(note){
clearAdminReveals();
st.authed=false;
if(note!==undefined){$("#login-note").textContent=note;$("#login-note").classList.toggle("hide",!note)}
show("#v-login");
@@ -856,7 +912,7 @@ function showLogin(note){
/* ---------- login ---------- */
var loginEpoch=0,loginResume=null;
function loginRequest(password){
return fetch("/api/login",{method:"POST",headers:{"Content-Type":"application/json"},
return fetch("/api/login",{method:"POST",headers:{"Content-Type":"application/json","X-WebConfig-Explicit-Login":"1"},
body:JSON.stringify({password:password})})
.then(function(r){return r.json().then(function(j){return{s:r.status,j:j}})});
}
@@ -878,12 +934,13 @@ $("#login-form").addEventListener("submit",function(ev){
}).catch(function(){if(epoch===loginEpoch){btn.disabled=false;$("#login-err").textContent="connection error"}});
password="";
});
function doLogout(){post("/api/logout",{}).then(function(){++loginEpoch;loginResume=null;showLogin("")}).catch(function(){})}
function doLogout(){clearAdminReveals();post("/api/logout",{}).then(function(){++loginEpoch;loginResume=null;showLogin("")}).catch(function(){})}
// A password save revokes the old session. Keep only a single in-memory login
// attempt, then let the ordinary form resume the same result read if needed.
// Never retry a settings/terminal POST: it may already have executed.
function reauthenticateOnce(auth,note,resume,keepView){
clearAdminReveals();
var view=auth&&auth.view||($("#v-wizard").classList.contains("hide")?"#v-app":"#v-wizard");
var epoch=++loginEpoch,password=auth&&!auth.attempted?auth.password:"";
if(auth&&auth.receiptSeen)setupPasswordPending(auth);
@@ -910,6 +967,7 @@ function reauthenticateOnce(auth,note,resume,keepView){
password="";
}
function forgetSubmittedPassword(submitted){
clearAdminReveals();
var password=submitted&&submitted.password;
if(!password)return;
if(st.dirty.password===password)delete st.dirty.password;
@@ -1111,6 +1169,7 @@ function syncCpuFrequency(c){
$("#cpu-frequency-status").textContent=available&&cpu.actual_mhz>0?"Running at "+cpu.actual_mhz+" MHz":"";
}
function loadConfig(loadCapture){
clearAdminReveals();
var epoch=++st.configEpoch;
if(loadCapture)loadCapture.loadEpoch=epoch;
return api("/api/config").then(function(c){
@@ -1174,6 +1233,7 @@ function afHint(){
}
document.addEventListener("input",function(ev){
var el=ev.target;
if(el.dataset.k==="password")clearAdminReveals(el);
if(el.dataset.k==="wifi.pwd"&&!ev.meshAutoWiFiPassword)st.wifiPasswordAutoCleared=false;
// Typed SSIDs follow the same safety rule as scan selections. A masked value
// means "retain the old network's secret" and must not follow a new network.
@@ -1525,6 +1585,7 @@ function pollResult(cb,errs,idles,acked,reqid,submitted,auth){
});
}
function loadConfigSoft(){ // re-sync origin values without clobbering result chips
clearAdminReveals();
api("/api/config").then(function(c){
st.cfg=c;
syncRegulators(c);