mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-11-04 14:27:16 +00:00
Improve emergency telemetry and password controls
This commit is contained in:
1 parent
236d12610c
commit
dbaf951359
20 files changed
+3320
-2436
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
+2461
-2412
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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()
|
||||
@@ -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;}
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user