Preserve admin password input and clarify reconnect guidance
This commit is contained in:
1 parent
ad4124b055
commit
5d741318f5
15 files changed
+2858
-2334
No files matched your search
@@ -36,8 +36,8 @@ bool AsyncElegantOtaClass::authenticateRequest(AsyncWebServerRequest* request,
|
||||
else if (result == mesh::esp32::managementPassword::AuthResult::Throttled)
|
||||
request->send(429, "text/plain", "too many failed logins; wait 30 seconds");
|
||||
else {
|
||||
AsyncWebServerResponse* response = request->beginResponse(401, "text/plain", "admin password required");
|
||||
response->addHeader("WWW-Authenticate", "Basic realm=\"MeshCore admin\", charset=\"UTF-8\"");
|
||||
AsyncWebServerResponse* response = request->beginResponse(401, "text/plain", "Sign in with username admin and this node's admin password.");
|
||||
response->addHeader("WWW-Authenticate", "Basic realm=\"MeshCore (username: admin)\", charset=\"UTF-8\"");
|
||||
response->addHeader("Cache-Control", "no-store");
|
||||
request->send(response);
|
||||
}
|
||||
|
||||
+41
-17
@@ -876,13 +876,23 @@ After this bootloader is flashed onto the device, you can trigger an over-the-ai
|
||||
filename).
|
||||
2. From the MeshCore app, log in remotely to the repeater you want to update with admin privileges.
|
||||
3. Go to the Command Line tab, type `start ota` and hit enter.
|
||||
4. The reply confirms WiFi is on and gives the OTA upload URL and network to use.
|
||||
5. If the ESP32 is not already joined to Wi-Fi, `start ota` starts an open hotspot named `MeshCore-OTA` at `192.168.4.1`. If it is already joined, the command reports and uses its router-assigned address instead.
|
||||
6. From your phone or computer, connect to the `MeshCore-OTA` hotspot when one was started.
|
||||
7. Open the URL reported by `start ota` and upload the non-merged bin from the flasher. When the device started `MeshCore-OTA`, the URL is <http://192.168.4.1/update>.
|
||||
4. The reply gives the OTA upload URL, network to use, and HTTP login details.
|
||||
5. If the ESP32 is not already joined to Wi-Fi, `start ota` starts the
|
||||
`MeshCore-OTA` hotspot at `192.168.4.1`. It uses the node's admin password for
|
||||
WPA2 when that password is 8–15 printable ASCII characters. Other accepted
|
||||
admin passwords leave the hotspot open; the HTTP login still applies. If
|
||||
the node is already joined, the command uses its router-assigned address.
|
||||
6. From your phone or computer, join `MeshCore-OTA` when one was started. Use
|
||||
the node's admin password if the WiFi connection asks for a password.
|
||||
7. Open the exact URL reported by `start ota`. Sign in with HTTP Basic username
|
||||
`admin` and the node's current admin password, then upload the non-merged
|
||||
application bin from the flasher. The URL normally uses port 80, or port
|
||||
8080 on Full Companion builds with browser OTA support.
|
||||
|
||||
`start ota` automatically stops WebConfig and reports the change; both servers
|
||||
use HTTP port 80. Stop the OTA uploader before running `start webconfig` again.
|
||||
`start ota` automatically stops WebConfig and reports the change. Stop the OTA
|
||||
uploader before running `start webconfig` again. Temporary partition-migration
|
||||
and recovery applications retain their documented access policy. See the
|
||||
[WiFi guide](WiFi.md) for the shared admin-password policy.
|
||||
|
||||
|
||||
### 7.3. Q: Is there a way to lower the chance of a failed OTA device firmware update (DFU)?
|
||||
@@ -925,22 +935,36 @@ Where `&type` is:
|
||||
### 7.6. Q: How do I connect to the companion via Wi-Fi, e.g. using a Heltec V3?
|
||||
**A:**
|
||||
Flash an ESP32 companion Wi-Fi build. Its shared WebUI is enabled by default. If
|
||||
it has no saved Wi-Fi configuration, it starts an open `MeshCore-Setup-XXXX`
|
||||
access point. Join it and browse to <http://192.168.4.1/>. The wizard configures
|
||||
WiFi, node identity, and radio settings; MQTT companion builds add an MQTT step
|
||||
and MQTT tab, while non-MQTT companions remove those controls. After saving,
|
||||
the page and device screen show the station IP assigned by your router.
|
||||
it has no saved Wi-Fi configuration, it starts a `MeshCore-Setup-XXXX` access
|
||||
point. The hotspot uses the node's admin password for WPA2 when it is 8–15
|
||||
printable ASCII characters; standard builds initially use `password`. Other
|
||||
accepted admin passwords leave the hotspot open and require an HTTP admin
|
||||
login. Join it and browse to <http://192.168.4.1/>. Protected first-time setup
|
||||
continues directly to the wizard, which configures WiFi, node identity, radio,
|
||||
and the admin password. MQTT companion builds add an MQTT step and tab, while
|
||||
non-MQTT companions remove those controls. After saving, the page and device
|
||||
screen show the station IP assigned by your router.
|
||||
|
||||
If a saved network remains unreachable for two minutes, the setup AP starts as
|
||||
a fallback. If the original network returns, the companion closes that recovery
|
||||
portal and resumes normal LAN operation.
|
||||
a fallback. Sign in with the existing admin password before changing settings
|
||||
on that recovery page. If the original network returns, the companion closes
|
||||
the recovery portal and resumes normal LAN operation.
|
||||
|
||||
The setup AP always uses `192.168.4.1`; the companion's station IP is assigned by DHCP and can be different. A real `WIFI_SSID` and `WIFI_PWD` can still be supplied at compile time as initial defaults, but they are no longer required.
|
||||
|
||||
On the LAN, the WebUI remains available at the companion's station IP and the
|
||||
companion protocol remains available on TCP port `5000`. Companion builds do not
|
||||
have an admin CLI password, so this configuration page trusts the local LAN; do
|
||||
not put the companion on an untrusted WiFi network.
|
||||
On the LAN, sign in to the WebUI at the companion's station IP with its saved
|
||||
admin password. ESP32 WiFi Companions share that password between WebConfig,
|
||||
protected setup WiFi, and runtime browser OTA; other Companion builds retain
|
||||
their existing no-admin-password behavior. Change it with
|
||||
`password <new-password>` on USB or another available administrative connection.
|
||||
An ESP32 WiFi Companion's password is stored in NVS and survives a filesystem
|
||||
erase; `get password` reports its availability without revealing it. If it
|
||||
reports an unavailable management password, set a new one over USB to repair it.
|
||||
|
||||
The Companion protocol on TCP port `5000` retains its trusted-network policy;
|
||||
the browser login does not protect that port. Use a trusted WiFi network. Router
|
||||
WiFi passwords, MQTT credentials, and Bluetooth PINs remain separate. See the
|
||||
[WiFi guide](WiFi.md) for password-change and reconnect behavior.
|
||||
|
||||
ESP32 repeater and room-server builds use the same page, but keep it off by
|
||||
default. Run `set webui on` from the admin CLI, open the reported LAN URL, and
|
||||
|
||||
@@ -244,8 +244,11 @@ class LightweightOTAServer {
|
||||
static const char throttled[] = "too many failed logins; wait 30 seconds";
|
||||
sendResponse(client, 429, "Too Many Requests", "text/plain", throttled, sizeof(throttled) - 1);
|
||||
} else {
|
||||
client.print("HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"MeshCore admin\", charset=\"UTF-8\"\r\n"
|
||||
"Content-Length: 0\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n");
|
||||
static const char login_help[] = "Sign in with username admin and this node's admin password.";
|
||||
client.printf("HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"MeshCore (username: admin)\", charset=\"UTF-8\"\r\n"
|
||||
"Content-Type: text/plain; charset=utf-8\r\nContent-Length: %u\r\n"
|
||||
"Connection: close\r\nCache-Control: no-store\r\n\r\n%s",
|
||||
static_cast<unsigned>(sizeof(login_help) - 1), login_help);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -375,11 +378,11 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
|
||||
const char* network = !use_ap ? "Use same WiFi/LAN"
|
||||
: mesh::esp32::managementPassword::apPasswordEligible(credential.password)
|
||||
? "Join WiFi MeshCore-OTA (admin password)"
|
||||
: "Join WiFi MeshCore-OTA (OPEN compatibility; HTTP admin login)";
|
||||
: "Join WiFi MeshCore-OTA (OPEN compatibility)";
|
||||
if (LIGHTWEIGHT_OTA_PORT == 80) {
|
||||
snprintf(reply, 160, "Started: http://%s/update - WiFi on; %s", ip.toString().c_str(), network);
|
||||
snprintf(reply, 160, "Started: http://%s/update; %s; Login: admin / node admin password", ip.toString().c_str(), network);
|
||||
} else {
|
||||
snprintf(reply, 160, "Started: http://%s:%u/update - WiFi on; %s", ip.toString().c_str(),
|
||||
snprintf(reply, 160, "Started: http://%s:%u/update; %s; Login: admin / node admin password", ip.toString().c_str(),
|
||||
static_cast<unsigned>(LIGHTWEIGHT_OTA_PORT), network);
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("startOTAUpdate: %s", reply);
|
||||
@@ -478,8 +481,8 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
|
||||
const char* network = !use_ap ? "Use same WiFi/LAN"
|
||||
: mesh::esp32::managementPassword::apPasswordEligible(credential.password)
|
||||
? "Join WiFi MeshCore-OTA (admin password)"
|
||||
: "Join WiFi MeshCore-OTA (OPEN compatibility; HTTP admin login)";
|
||||
snprintf(reply, 160, "Started: http://%s/update - WiFi on; %s", ip.toString().c_str(), network);
|
||||
: "Join WiFi MeshCore-OTA (OPEN compatibility)";
|
||||
snprintf(reply, 160, "Started: http://%s/update; %s; Login: admin / node admin password", ip.toString().c_str(), network);
|
||||
MESH_DEBUG_PRINTLN("startOTAUpdate: %s", reply);
|
||||
|
||||
// Select the requested network even when the HTTP listener already exists.
|
||||
|
||||
@@ -317,7 +317,9 @@ static void portalTask(void* arg) {
|
||||
if (!mesh::wifi::startManagementAccessPoint(impl->ap_name, false)) break;
|
||||
impl->ap_password_generation = current.generation;
|
||||
impl->rekey_generation = 0;
|
||||
mesh::usbLoggingPort().println("Setup WiFi updated; reconnect using the admin password");
|
||||
mesh::usbLoggingPort().println(apPasswordEligible(current.password)
|
||||
? "Setup WiFi updated; reconnect using the admin password"
|
||||
: "Setup WiFi updated; join the open network, then sign in as admin with the node admin password");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2321
-2282
File diff suppressed because it is too large.
Load diff
@@ -275,7 +275,14 @@ static const char* wcCliUnavailable(const char* cmd, bool streamed = false) {
|
||||
// are served to the client over the open setup AP. The config path overwrites it
|
||||
// by key; a CLI entry has no key, so match on the command itself.
|
||||
static inline bool wcCliEchoesSecret(const char* cmd) {
|
||||
return strncmp(cmd, "password ", 9) == 0;
|
||||
static const char verb[] = "password ";
|
||||
if (!cmd) return false;
|
||||
for (size_t i = 0; i < sizeof(verb) - 1; ++i) {
|
||||
char c = cmd[i];
|
||||
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
|
||||
if (c != verb[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// CLI requests require LAN mode and authentication. Explicit getters have
|
||||
@@ -3048,12 +3055,13 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) {
|
||||
req->send(413, "application/json", out);
|
||||
return;
|
||||
}
|
||||
// Strip CR/LF so one entry cannot smuggle a second command past the
|
||||
// operator's confirmation, and skip whatever is left blank.
|
||||
// Keep ordinary command normalization compatible, but a password is an
|
||||
// exact value: preserve its suffix and reject line breaks, never strip them.
|
||||
BatchEntry& e = _batch[count];
|
||||
int pos = 0;
|
||||
bool line_break = false;
|
||||
for (const char* p = raw; *p; p++) {
|
||||
if (*p == '\r' || *p == '\n') continue;
|
||||
if (*p == '\r' || *p == '\n') { line_break = true; continue; }
|
||||
if (pos == 0 && (*p == ' ' || *p == '\t')) continue; // leading space
|
||||
if (pos >= (int)sizeof(e.cmd) - 1) {
|
||||
req->send(400, "application/json", "{\"error\":\"command too long\"}");
|
||||
@@ -3061,8 +3069,17 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) {
|
||||
}
|
||||
e.cmd[pos++] = *p;
|
||||
}
|
||||
while (pos > 0 && (e.cmd[pos - 1] == ' ' || e.cmd[pos - 1] == '\t')) pos--;
|
||||
e.cmd[pos] = 0;
|
||||
if (wcCliEchoesSecret(e.cmd)) {
|
||||
if (line_break) {
|
||||
req->send(400, "application/json", "{\"error\":\"admin password must not contain line breaks\"}");
|
||||
return;
|
||||
}
|
||||
mesh::cli::normalizeCommandVerb(e.cmd);
|
||||
} else {
|
||||
while (pos > 0 && (e.cmd[pos - 1] == ' ' || e.cmd[pos - 1] == '\t')) pos--;
|
||||
e.cmd[pos] = 0;
|
||||
}
|
||||
if (pos == 0) continue;
|
||||
// Reject before anything runs, so a sequence never half-applies and then
|
||||
// stops on a command that was never going to work here.
|
||||
@@ -3077,7 +3094,7 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) {
|
||||
}
|
||||
e.key[0] = 0; // CLI entries have no config key
|
||||
if (wcIsDeferredReboot(e.cmd)) defer_reboot = true;
|
||||
if (strncmp(e.cmd, "password ", 9) == 0) seq_sets_pwd = true;
|
||||
if (wcCliEchoesSecret(e.cmd)) seq_sets_pwd = true;
|
||||
if (strncmp(e.cmd, "set wifi.ssid ", 14) == 0) seq_sets_ssid = true;
|
||||
count++;
|
||||
}
|
||||
|
||||
+2
-1
@@ -206,7 +206,8 @@ int main() {
|
||||
Request absent; get(&absent); assert(absent.code == 401);
|
||||
assert(absent.queued_response->headers.size() == 2);
|
||||
assert(absent.queued_response->headers[0].first == "WWW-Authenticate"
|
||||
&& absent.queued_response->headers[0].second == "Basic realm=\"MeshCore admin\", charset=\"UTF-8\"");
|
||||
&& absent.queued_response->headers[0].second == "Basic realm=\"MeshCore (username: admin)\", charset=\"UTF-8\"");
|
||||
assert(absent.body == "Sign in with username admin and this node's admin password.");
|
||||
assert(absent.queued_response->headers[1].first == "Cache-Control"
|
||||
&& absent.queued_response->headers[1].second == "no-store");
|
||||
Request wrong; authorize(wrong, "Basic wrong"); get(&wrong); assert(wrong.code == 401);
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
// Real JSON CLI parser, form command builder, drain dispatch and both real
|
||||
// role password setters. Only auth entry and persistence/peripherals are fake.
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <ArduinoJson.h>
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST
|
||||
#define ESP32_PLATFORM
|
||||
#define ESP_PLATFORM
|
||||
#define WIFI_SSID ""
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <helpers/WebConfigBatch.h>
|
||||
#include <helpers/WebConfigKeys.h>
|
||||
namespace password = mesh::esp32::managementPassword;
|
||||
using String = std::string;
|
||||
using WCLock = std::lock_guard<std::mutex>;
|
||||
uint32_t millis() { return 1000; }
|
||||
bool save_ok = true;
|
||||
unsigned saves = 0;
|
||||
bool durableSave() { ++saves; return save_ok; }
|
||||
namespace mesh { namespace esp32 { namespace companionAdminPassword {
|
||||
bool save(const char*) { return durableSave(); }
|
||||
}} 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; }
|
||||
}
|
||||
struct StrHelper { static void strncpy(char* out, const char* in, size_t size) {
|
||||
::strncpy(out, in, size); out[size - 1] = 0;
|
||||
}};
|
||||
struct CommonCLI {
|
||||
struct Prefs { char password[16] = "original"; } prefs;
|
||||
Prefs* _prefs = &prefs;
|
||||
bool trySavePrefs() { return durableSave(); }
|
||||
void command(char* command, char* reply) {
|
||||
@SETTER@
|
||||
}
|
||||
};
|
||||
struct MyMesh {
|
||||
char _management_password[16] = "original";
|
||||
bool _management_password_ready = true;
|
||||
bool handleLocalControlCommand(const char*, char*, size_t);
|
||||
void command(char* cmd, char* reply) { handleLocalControlCommand(cmd, reply, 160); }
|
||||
};
|
||||
@COMPANION@
|
||||
struct Callback {
|
||||
bool companion = false;
|
||||
unsigned starts = 0, ends = 0, calls = 0;
|
||||
CommonCLI infrastructure; MyMesh node;
|
||||
bool supportsCliTerminal() const { return true; }
|
||||
void execCommand(char* cmd, char* reply) {
|
||||
++calls;
|
||||
if (companion) node.command(cmd, reply); else infrastructure.command(cmd, reply);
|
||||
}
|
||||
void execAdminCommand(char* cmd, char* reply) { execCommand(cmd, reply); }
|
||||
void onConfigBatchStart() { ++starts; }
|
||||
void onConfigBatchEnd() { ++ends; }
|
||||
};
|
||||
struct AsyncWebServerRequest {
|
||||
int status = 0;
|
||||
std::string body, reply;
|
||||
void* _tempObject = nullptr;
|
||||
size_t contentLength() const { return body.size(); }
|
||||
void send(int code, const char*, const char* text) { status = code; reply = text; }
|
||||
void send(int code, const char*, const String& text) { status = code; reply = text; }
|
||||
void payload(const std::string& text) { body = text; _tempObject = &body[0]; }
|
||||
};
|
||||
static const char SECRET_SENTINEL[] = "********";
|
||||
static bool isAllowedSetKey(const char*, bool);
|
||||
static bool isSecretKey(const char*);
|
||||
struct WebConfigServer {
|
||||
static constexpr unsigned MAX_BATCH = 24, MAX_BODY = 6144;
|
||||
struct BatchEntry { char key[33] = {}, cmd[160] = {}, reply[160] = {}; } _batch[MAX_BATCH];
|
||||
enum Mode { MODE_OFF, MODE_SETUP, MODE_LAN } _mode = MODE_LAN;
|
||||
enum BatchKind { BATCH_CONFIG, BATCH_CLI } _batch_kind = BATCH_CLI;
|
||||
enum State { BATCH_IDLE, BATCH_PENDING, BATCH_DONE } _batch_state = BATCH_IDLE;
|
||||
std::mutex _mux;
|
||||
Callback cb; Callback* _cb = &cb;
|
||||
bool _cli_enabled = true, _initial_setup = false, _admin_pwd_set = false;
|
||||
bool _batch_reboot = false, _batch_reboot_armed = false, _batch_all_ok = true;
|
||||
bool _batch_started = false, _batch_reauth_required = false, _batch_reauth_delivered = false;
|
||||
bool _standalone_wifi = false, _standalone_wifi_dirty = false;
|
||||
int _batch_count = 0, _batch_next = 0;
|
||||
uint32_t _batch_auth_generation = 0, _batch_reauth_until = 0, _batch_last_cmd = 0;
|
||||
uint32_t _session_generation = 0, _session_last_seen = 1;
|
||||
char _batch_owner_token[33] = {}, _session_token[33] = "0123456789abcdef0123456789abcdef";
|
||||
char _batch_reqid[24] = {}, _wifi_ssid[32] = {}, _wifi_password[65] = {};
|
||||
uint8_t _wifi_power_save = 0;
|
||||
void* _mqtt_prefs = nullptr;
|
||||
bool checkAuth(AsyncWebServerRequest*) { return true; }
|
||||
bool checkAuthLocked(AsyncWebServerRequest*, uint32_t* generation) {
|
||||
password::Snapshot value; assert(password::snapshot(value)); *generation = value.generation; return true;
|
||||
}
|
||||
bool bluetoothWiFiCoexistenceRequired() const { return false; }
|
||||
void reloadStandaloneWiFi() {}
|
||||
static WebConfigBatch::State toSpecState(State value) { return static_cast<WebConfigBatch::State>(value); }
|
||||
void clearSessionLocked();
|
||||
void beginBatchAuthorizationLocked(uint32_t);
|
||||
void cancelStaleBatchLocked(uint32_t);
|
||||
void handleCliPost(AsyncWebServerRequest*);
|
||||
void drainBatch(uint32_t);
|
||||
void configForTest(AsyncWebServerRequest* req) {
|
||||
DynamicJsonDocument doc(6144); assert(!deserializeJson(doc, req->body));
|
||||
JsonObject set = doc["set"];
|
||||
int count = 0;
|
||||
@CONFIG_LOOP@
|
||||
_batch_count = count; _batch_next = 0; _batch_kind = BATCH_CONFIG;
|
||||
password::Snapshot value; assert(password::snapshot(value));
|
||||
beginBatchAuthorizationLocked(value.generation); _batch_state = BATCH_PENDING;
|
||||
req->send(202, "application/json", "{}");
|
||||
}
|
||||
};
|
||||
@METHODS@
|
||||
void reset() {
|
||||
auto& state = password::detail::state();
|
||||
{ password::detail::Lock lock(state); state.current = {}; state.changing = state.uploading = state.rotating = false; }
|
||||
assert(password::initialize("original")); save_ok = true; saves = 0;
|
||||
}
|
||||
std::string cliPayload(const std::string& command, bool prepend = false) {
|
||||
DynamicJsonDocument doc(6144); doc["reqid"] = "0123456789abcdef";
|
||||
JsonArray cmds = doc.createNestedArray("cmds"); if (prepend) cmds.add("get name"); cmds.add(command);
|
||||
std::string out; serializeJson(doc, out); return out;
|
||||
}
|
||||
int main() {
|
||||
for (bool companion : {false, true}) {
|
||||
for (bool config : {false, true}) {
|
||||
for (bool success : {false, true}) {
|
||||
reset(); WebConfigServer server; server.cb.companion = companion; save_ok = success;
|
||||
const char* value = " NewSecret ";
|
||||
AsyncWebServerRequest req;
|
||||
if (config) {
|
||||
req.payload(std::string("{\"set\":{\"password\":\"") + value + "\"}}");
|
||||
server.configForTest(&req);
|
||||
} else {
|
||||
req.payload(cliPayload(std::string(" PaSsWoRd ") + value)); server.handleCliPost(&req);
|
||||
}
|
||||
assert(req.status == 202 && server._batch_count == 1);
|
||||
assert(!strcmp(server._batch[0].cmd, "password NewSecret "));
|
||||
server.drainBatch(1000);
|
||||
password::Snapshot current; assert(password::snapshot(current));
|
||||
assert(server.cb.calls == 1 && saves == 1);
|
||||
assert(!strcmp(current.password, success ? value : "original"));
|
||||
const char* runtime = companion ? server.cb.node._management_password
|
||||
: server.cb.infrastructure.prefs.password;
|
||||
assert(!strcmp(runtime, success ? value : "original"));
|
||||
assert(server._batch_reauth_required == success);
|
||||
assert(bool(server._session_token[0]) != success);
|
||||
assert(!strcmp(server._batch[0].reply, "OK") == success);
|
||||
assert(strstr(server._batch[0].reply, "NewSecret") == nullptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const char* command : {"PASSWORD bad\rvalue", "password bad\nvalue", "pass\nword rejected"}) {
|
||||
reset(); WebConfigServer server; AsyncWebServerRequest req;
|
||||
req.payload(cliPayload(command, true)); server.handleCliPost(&req);
|
||||
assert(req.status == 400 && server._batch_state == WebConfigServer::BATCH_IDLE);
|
||||
assert(server.cb.calls == 0 && saves == 0);
|
||||
}
|
||||
reset(); WebConfigServer ordinary; AsyncWebServerRequest req;
|
||||
req.payload(cliPayload(" get name\r\n \t")); ordinary.handleCliPost(&req);
|
||||
assert(req.status == 202 && !strcmp(ordinary._batch[0].cmd, "get name"));
|
||||
}
|
||||
Vendored
+3
-1
@@ -251,11 +251,13 @@ int main() {
|
||||
+ "/update";
|
||||
assert(strncmp(reply.text, url.c_str(), url.size()) == 0);
|
||||
assert(strstr(reply.text, instruction));
|
||||
assert(strstr(reply.text, "Login: admin / node admin password"));
|
||||
if (ap || !WiFi.connected) {
|
||||
assert(WiFi.ap_protocol == 7);
|
||||
assert(WiFi.ap_channel == (constrain_espnow_channel ? 6 : 1));
|
||||
}
|
||||
assert(strlen(reply.text) < 160 && reply.guard == 0x12345678);
|
||||
assert(strlen(reply.text) + strlen("; WebConfig stopped") < 160
|
||||
&& reply.guard == 0x12345678);
|
||||
};
|
||||
// Normal start reports the actual LAN address and does not disconnect it.
|
||||
start(false, "10.20.30.40", "same WiFi/LAN");
|
||||
|
||||
@@ -76,7 +76,10 @@ int main() {
|
||||
for (const char* path : {"/", "/update", "/log"}) {
|
||||
const auto absent = request("GET", path, "");
|
||||
assert(absent.find("401 Unauthorized") != std::string::npos
|
||||
&& absent.find("WWW-Authenticate: Basic realm=\"MeshCore admin\", charset=\"UTF-8\"") != std::string::npos);
|
||||
&& absent.find("WWW-Authenticate: Basic realm=\"MeshCore (username: admin)\", charset=\"UTF-8\"") != std::string::npos);
|
||||
const std::string help = "Sign in with username admin and this node's admin password.";
|
||||
assert(absent.find("Content-Length: " + std::to_string(help.size()) + "\r\n") != std::string::npos);
|
||||
assert(absent.substr(absent.find("\r\n\r\n") + 4) == help);
|
||||
assert(request("GET", path, "Authorization: Basic wrong\r\n").find("401 Unauthorized") != std::string::npos);
|
||||
assert(request("GET", path, good).find("200 OK") != std::string::npos);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,45 @@ class WebConfigPasswordSessionsTest(unittest.TestCase):
|
||||
self.assertNotEqual(old, fixture)
|
||||
self.compile_run(old, expect_rejection=True, includes=self.receipt_json_include())
|
||||
|
||||
def command_fixture(self):
|
||||
source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
|
||||
methods = "\n".join(extract_braced(source, signature) for signature in (
|
||||
"static bool fixedTimeEquals(", "static inline bool wcCliEchoesSecret(",
|
||||
"static inline bool wcIsDeferredReboot(", "static const char* wcCliUnavailable(",
|
||||
"static bool isAllowedSetKey(", "static bool isSecretKey(",
|
||||
"void WebConfigServer::clearSessionLocked(",
|
||||
"void WebConfigServer::beginBatchAuthorizationLocked(",
|
||||
"void WebConfigServer::cancelStaleBatchLocked(",
|
||||
"void WebConfigServer::handleCliPost("))
|
||||
drain = extract_braced(source, "void WebConfigServer::drainBatch(")
|
||||
# Run actual command dispatch, masking, and committed-generation hook.
|
||||
# Subsequent WiFi/reboot handoff is covered by its dedicated fixtures.
|
||||
methods += "\n" + drain[:drain.index(" if (_standalone_wifi_dirty)")] + "\n}\n"
|
||||
config = extract_braced(source, "void WebConfigServer::handleConfigPost(")
|
||||
config_loop = extract_braced(config[config.index(" int count = 0;"):], " for (JsonPair kv : set)")
|
||||
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
||||
start = cli.index(' } else if (memcmp(command, "password ", 9) == 0) {')
|
||||
end = cli.index(' } else if (memcmp(command, "clear stats",', start)
|
||||
setter = cli[start:end].replace('} else if (', 'if (', 1) + '\n }\n'
|
||||
companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
|
||||
start = companion.index('bool MyMesh::handleLocalControlCommand(')
|
||||
end = companion.index(' if (mesh::powerdiag::handleCommand(', start)
|
||||
companion = companion[start:end] + ' return false;\n}\n'
|
||||
return (ROOT / "test/fixtures/webconfig_password_command.cpp").read_text().replace(
|
||||
"@METHODS@", methods).replace("@CONFIG_LOOP@", config_loop).replace(
|
||||
"@SETTER@", setter).replace("@COMPANION@", companion)
|
||||
|
||||
def test_actual_parser_drain_and_role_setters_preserve_password_bytes(self):
|
||||
self.compile_run(self.command_fixture(), includes=self.receipt_json_include())
|
||||
|
||||
def test_old_cli_suffix_trimming_fails_executable_regression(self):
|
||||
fixture = self.command_fixture()
|
||||
old = fixture.replace(' if (wcCliEchoesSecret(e.cmd)) {',
|
||||
" while (pos > 0 && (e.cmd[pos - 1] == ' ' || e.cmd[pos - 1] == '\\t')) --pos;\n"
|
||||
" e.cmd[pos] = 0;\n if (wcCliEchoesSecret(e.cmd)) {", 1)
|
||||
self.assertNotEqual(old, fixture)
|
||||
self.compile_run(old, expect_rejection=True, includes=self.receipt_json_include())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1120,6 +1120,182 @@ window.addEventListener("load",function(){
|
||||
self.assertIn('data-test-withheld="true"', dom)
|
||||
self.assertIn('data-test-error-detail="true"', dom)
|
||||
|
||||
def test_typed_and_pasted_cli_password_preserves_argument_bytes_for_reauth(self):
|
||||
for entry in ("typed", "whole_paste", "partial_single", "partial_block"):
|
||||
with self.subTest(entry=entry):
|
||||
status, config = self.setup_values()
|
||||
status.update(mode="lan", cli=True, terminal_stream=False)
|
||||
prelude = r'''<script>
|
||||
(function(){
|
||||
var entry=%s,status=%s,config=%s,submitted=null,posts=0,logins=0,polls=0,authorized=true;
|
||||
var expected=entry==="typed"?["password typed "]:entry==="whole_paste"?["PaSsWoRd pasted ","get name"]
|
||||
:entry==="partial_single"?["password suffix "]:["password suffix ","get name"];
|
||||
var exactCommands=false,exactLogin=false,exactContext=false;
|
||||
function response(value,code){code=code||200;return Promise.resolve({ok:code<400,status:code,json:function(){return Promise.resolve(value)}})}
|
||||
window.fetch=function(path,options){
|
||||
if(path==="/api/status")return response(status);
|
||||
if(path==="/api/config")return response(config);
|
||||
if(path==="/api/cli"&&options&&options.method==="POST"){
|
||||
posts++;submitted=JSON.parse(options.body);authorized=false;
|
||||
exactCommands=JSON.stringify(submitted.cmds)===JSON.stringify(expected);
|
||||
exactContext=cliPasswordContext(submitted.cmds).password===expected[0].slice(9);
|
||||
return response({state:"pending",reqid:submitted.reqid},202);
|
||||
}
|
||||
if(path.indexOf("/api/cli/result?")===0){
|
||||
polls++;
|
||||
if(path.indexOf("reqid="+submitted.reqid)<0)throw new Error("lost request id");
|
||||
return response({state:"done",reqid:submitted.reqid,all_ok:true,reauth_required:!authorized,
|
||||
results:submitted.cmds.map(function(){return{ok:true,reply:"OK"}})});
|
||||
}
|
||||
if(path==="/api/login"){
|
||||
logins++;exactLogin=JSON.parse(options.body).password===expected[0].slice(9);
|
||||
authorized=exactLogin;return response(exactLogin?{ok:true}:{error:"wrong password"},exactLogin?200:401);
|
||||
}
|
||||
return response({},404);
|
||||
};
|
||||
window.addEventListener("load",function(){
|
||||
setTimeout(function(){
|
||||
var input=document.getElementById("term-in");
|
||||
if(entry!=="typed"){
|
||||
input.value=entry==="whole_paste"?"":"password ";
|
||||
var event=new Event("paste",{bubbles:true,cancelable:true});
|
||||
Object.defineProperty(event,"clipboardData",{value:{getData:function(){return entry==="whole_paste"
|
||||
?" > PaSsWoRd pasted \r\n get name \t\r\n # comment\r\n"
|
||||
:entry==="partial_single"?"suffix \n":"suffix \nget name"}}});
|
||||
input.dispatchEvent(event);
|
||||
if(entry==="partial_single")input.dispatchEvent(new KeyboardEvent("keydown",{key:"Enter",bubbles:true,cancelable:true}));
|
||||
}else{
|
||||
input.value=" password typed ";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown",{key:"Enter",bubbles:true,cancelable:true}));
|
||||
}
|
||||
var go=document.querySelector(".term-cfm .go");if(go)go.click();
|
||||
},120);
|
||||
setTimeout(function(){
|
||||
var body=document.body;
|
||||
body.setAttribute("data-test-posts",posts);body.setAttribute("data-test-logins",logins);body.setAttribute("data-test-polls",polls);
|
||||
body.setAttribute("data-test-exact-commands",exactCommands);body.setAttribute("data-test-exact-context",exactContext);
|
||||
body.setAttribute("data-test-exact-login",exactLogin);body.setAttribute("data-test-busy",cli.busy);
|
||||
body.setAttribute("data-test-login-visible",!document.getElementById("v-login").classList.contains("hide"));
|
||||
body.setAttribute("data-test-normal-trim",cliCommandLine(" get name \t")==="get name");
|
||||
body.setAttribute("data-test-empty-password",cliParse(" PASSWORD \n")[0]==="PASSWORD ");
|
||||
body.setAttribute("data-test-mixedcase-risk",cliRisks(["PaSsWoRd secret "]).length===1);
|
||||
body.setAttribute("data-test-password-seen",cli.pwd===true);
|
||||
},600);
|
||||
});
|
||||
})();
|
||||
</script>''' % (json.dumps(entry), json.dumps(status), json.dumps(config))
|
||||
dom = self.run_page(prelude, virtual_time=900)
|
||||
for attr, expected in (("posts", "1"), ("logins", "1"), ("polls", "2"),
|
||||
("exact-commands", "true"), ("exact-context", "true"),
|
||||
("exact-login", "true"), ("busy", "false"),
|
||||
("login-visible", "false"), ("normal-trim", "true"),
|
||||
("empty-password", "true"), ("mixedcase-risk", "true"),
|
||||
("password-seen", "true")):
|
||||
self.assertIn(f'data-test-{attr}="{expected}"', dom)
|
||||
|
||||
def test_setup_security_banner_and_live_password_preview(self):
|
||||
banners = {
|
||||
"admin_password": "Setup WiFi uses this node's admin password.",
|
||||
"open_admin_login": "Setup WiFi is open; web configuration and firmware uploads still require this node's admin password.",
|
||||
"open_no_password": "Setup WiFi is open. This build has no admin password configured.",
|
||||
"unavailable": "Setup WiFi security is unavailable.",
|
||||
"missing": "Setup WiFi security is unavailable.",
|
||||
"pending": "Setup WiFi restart pending. Reconnect after the hotspot restarts to check its security.",
|
||||
"lan": "Setup WiFi uses this node's admin password.",
|
||||
}
|
||||
for security, banner in banners.items():
|
||||
with self.subTest(security=security):
|
||||
status, config = self.setup_values()
|
||||
status.update(mode="lan" if security == "lan" else "setup")
|
||||
if security != "missing":
|
||||
status["ap_security"] = "admin_password" if security in ("lan", "pending") else security
|
||||
status["ap_restart_pending"] = security == "pending"
|
||||
vectors = ["abcdefgh", "a" * 15, "a" * 7, "a" * 16,
|
||||
"é" * 4, "abc\x1fdefg", "~~~~~~~~", " " * 8, "abcd\x7fefg", ""]
|
||||
prelude = r'''<script>
|
||||
(function(){
|
||||
var status=%s,config=%s,values=%s;
|
||||
window.fetch=function(path){var value=path==="/api/status"?status:path==="/api/config"?config:{};return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(value)}})};
|
||||
window.addEventListener("load",function(){setTimeout(function(){
|
||||
var body=document.body,input=document.querySelector('#v-wizard [data-k="password"]');
|
||||
var previews=values.map(function(value){
|
||||
input.value=value;input.dispatchEvent(new Event("input",{bubbles:true}));
|
||||
var hints=Array.prototype.slice.call(document.querySelectorAll("[data-admin-ap-hint]"));
|
||||
var hidden=hints.every(function(h){return h.classList.contains("hide")});
|
||||
return hidden?"hidden":hints.every(function(h){return h.textContent==="Setup WiFi will use this password."})?"protected":hints.every(function(h){return h.textContent==="WiFi will be open; web configuration and firmware uploads still require this password."})?"open":"wrong";
|
||||
});
|
||||
body.setAttribute("data-test-banner",document.getElementById("setup-security").textContent);
|
||||
body.setAttribute("data-test-banner-hidden",document.getElementById("setup-security").classList.contains("hide"));
|
||||
body.setAttribute("data-test-previews",previews.join(","));
|
||||
body.setAttribute("data-test-login-default",document.querySelector("#v-login code").textContent);
|
||||
},200)});
|
||||
})();
|
||||
</script>''' % (json.dumps(status), json.dumps(config), json.dumps(vectors))
|
||||
dom = self.run_page(prelude, virtual_time=500)
|
||||
self.assertIn('data-test-banner="%s"' % banner, dom)
|
||||
self.assertIn('data-test-banner-hidden="%s"' % str(security == "lan").lower(), dom)
|
||||
self.assertIn('data-test-previews="%s"' % (
|
||||
",".join(["hidden"] * 10) if security == "lan" else
|
||||
"protected,protected,open,open,open,open,protected,protected,open,hidden"), dom)
|
||||
self.assertIn('data-test-login-default="password"', dom)
|
||||
|
||||
def test_setup_password_receipt_reconnect_guidance_matches_new_ap_policy(self):
|
||||
for password, protected, legacy in (("new-admin", True, False), ("éééé", False, False),
|
||||
("éééé", False, True)):
|
||||
with self.subTest(protected=protected, legacy=legacy):
|
||||
status, config = self.setup_values()
|
||||
status.update(mode="setup", ap_security="admin_password")
|
||||
prelude = r'''<script>
|
||||
(function(){
|
||||
var status=%s,config=%s,password=%s,legacy=%s,submitted=null,authSeen=null,posts=0,logins=0,polls=0,authorized=true;
|
||||
function response(value,code){code=code||200;return Promise.resolve({ok:code<400,status:code,json:function(){return Promise.resolve(value)}})}
|
||||
window.fetch=function(path,options){
|
||||
if(path==="/api/status")return response(status);
|
||||
if(path==="/api/config"&&options&&options.method==="POST"){
|
||||
posts++;submitted=JSON.parse(options.body);authorized=false;return response({state:"pending",reqid:submitted.reqid},202);
|
||||
}
|
||||
if(path.indexOf("/api/config/result?")===0){
|
||||
polls++;if(legacy&&!authorized)return response({error:"auth"},401);
|
||||
return response({state:"done",reqid:submitted.reqid,all_ok:true,reauth_required:!authorized,results:[{key:"password",reply:"OK"}]});
|
||||
}
|
||||
if(path==="/api/login"){
|
||||
logins++;authorized=JSON.parse(options.body).password===password;return response(authorized?{ok:true}:{error:"wrong password"},authorized?200:401);
|
||||
}
|
||||
if(path==="/api/config")return response(config);
|
||||
if(path.indexOf("/api/scan")===0)return response({state:"done",networks:[]});
|
||||
return response({},404);
|
||||
};
|
||||
window.addEventListener("load",function(){
|
||||
setTimeout(function(){
|
||||
var realReauth=reauthenticateOnce;reauthenticateOnce=function(auth,note,resume,keepView){authSeen=auth;return realReauth(auth,note,resume,keepView)};
|
||||
st.dirty.password=password;saveChanges();
|
||||
},120);
|
||||
setTimeout(function(){
|
||||
var body=document.body;
|
||||
body.setAttribute("data-test-posts",posts);body.setAttribute("data-test-logins",logins);body.setAttribute("data-test-polls",polls);
|
||||
body.setAttribute("data-test-pending-banner",document.getElementById("setup-security").textContent);
|
||||
body.setAttribute("data-test-note",document.getElementById("login-note").textContent);
|
||||
body.setAttribute("data-test-ram-secret-cleared",!!authSeen&&authSeen.password==="");
|
||||
body.setAttribute("data-test-ap-protected",authSeen&&authSeen.apProtected);
|
||||
},500);
|
||||
});
|
||||
})();
|
||||
</script>''' % (json.dumps(status), json.dumps(config), json.dumps(password), json.dumps(legacy))
|
||||
dom = self.run_page(prelude, virtual_time=800)
|
||||
for attr, expected in (("posts", "1"), ("logins", "1"), ("polls", "2"),
|
||||
("ram-secret-cleared", "true"), ("ap-protected", str(protected).lower())):
|
||||
self.assertIn(f'data-test-{attr}="{expected}"', dom)
|
||||
reconnect = ("Rejoin it with the new admin password." if protected else
|
||||
"Join it without a WiFi password, then sign in with the new admin password.")
|
||||
initial_note = ("Settings were sent. Sign in to read the saved result." if legacy else
|
||||
"Settings saved. The admin password changed.")
|
||||
self.assertIn('data-test-note="%s The setup WiFi may restart. %s"' %
|
||||
(initial_note, reconnect), dom)
|
||||
target = ("rejoin with the new admin password." if protected else
|
||||
"join without a WiFi password; web configuration and firmware uploads still require the new admin password.")
|
||||
self.assertIn('data-test-pending-banner="Setup WiFi restart pending. '
|
||||
'After it restarts, %s"' % target, dom)
|
||||
|
||||
def test_admin_password_validation_counts_utf8_bytes(self):
|
||||
status, config = self.setup_values()
|
||||
status.update(mode="lan")
|
||||
|
||||
@@ -417,8 +417,8 @@ class WifiOtaLifecycleTests(unittest.TestCase):
|
||||
def assert_healthy_ota(self, scenario, url, ap_starts):
|
||||
observation = self.observations[scenario]
|
||||
self.assertTrue(observation["first_started"] and observation["started"])
|
||||
network = "Join WiFi MeshCore-OTA (OPEN compatibility; HTTP admin login)" if url == "192.168.4.1" else "Use same WiFi/LAN"
|
||||
self.assertEqual(observation["reply"], f"Started: http://{url}/update - WiFi on; {network}")
|
||||
network = "Join WiFi MeshCore-OTA (OPEN compatibility)" if url == "192.168.4.1" else "Use same WiFi/LAN"
|
||||
self.assertEqual(observation["reply"], f"Started: http://{url}/update; {network}; Login: admin / node admin password")
|
||||
self.assertEqual(observation["server_count"], 1)
|
||||
self.assertEqual(observation["server_begin_calls"], 1)
|
||||
self.assertEqual(observation["ap_start_calls"], ap_starts)
|
||||
|
||||
@@ -187,6 +187,7 @@ int main() {
|
||||
ESP32Board board; char reply[160];
|
||||
assert(board.startOTAUpdate("fixture", reply, true));
|
||||
assert(strstr(reply, "(admin password)") && WiFi.connected);
|
||||
assert(strstr(reply, "Login: admin / node admin password"));
|
||||
assert(WiFi.live_config.ap.authmode == WIFI_AUTH_WPA2_PSK);
|
||||
assert(!strcmp(reinterpret_cast<char*>(WiFi.live_config.ap.password), "password"));
|
||||
const unsigned starts = WiFi.starts;
|
||||
|
||||
+65
-14
@@ -226,13 +226,14 @@ body.tab-cli{padding-bottom:0}
|
||||
aria-label="Open console">>_</button>
|
||||
</header>
|
||||
<main>
|
||||
<div id="setup-security" class="note hide" role="status"></div>
|
||||
<div id="login-note" class="note hide" role="status"></div>
|
||||
|
||||
<!-- ============ LOGIN ============ -->
|
||||
<section id="v-login" class="hide">
|
||||
<div class="card">
|
||||
<h2>Admin login</h2>
|
||||
<p style="font-size:13.5px;color:var(--mut);margin-bottom:12px">Enter this node's admin password.</p>
|
||||
<p style="font-size:13.5px;color:var(--mut);margin-bottom:12px">Use this node's admin password. Standard builds start with <code>password</code>; keep using yours if you've already changed it.</p>
|
||||
<form id="login-form">
|
||||
<div class="f"><label for="login-pwd">Password</label>
|
||||
<input type="password" id="login-pwd" autocomplete="current-password" autofocus></div>
|
||||
@@ -291,7 +292,8 @@ body.tab-cli{padding-bottom:0}
|
||||
<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">
|
||||
<div class="hint">Maximum 15 UTF-8 bytes. Use 8-15 printable ASCII characters to also protect the setup WiFi.</div></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>
|
||||
@@ -378,7 +380,8 @@ body.tab-cli{padding-bottom:0}
|
||||
<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>
|
||||
<input type="password" data-k="password" maxlength="15" autocomplete="new-password">
|
||||
<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>
|
||||
@@ -664,6 +667,31 @@ function toggleWiFiPassword(btn){
|
||||
btn.setAttribute("aria-label",(show?"Hide":"Show")+" WiFi password");
|
||||
input.focus();
|
||||
}
|
||||
function adminApEligible(password){return /^[\x20-\x7e]{8,15}$/.test(password)}
|
||||
function updateSetupSecurity(){
|
||||
var setup=st.mode==="setup",banner=$("#setup-security");
|
||||
banner.classList.toggle("hide",!setup);
|
||||
banner.textContent=st.apRestartPending?(st.apPendingProtected===true
|
||||
?"Setup WiFi restart pending. After it restarts, rejoin with the new admin password."
|
||||
:st.apPendingProtected===false
|
||||
?"Setup WiFi restart pending. After it restarts, join without a WiFi password; web configuration and firmware uploads still require the new admin password."
|
||||
:"Setup WiFi restart pending. Reconnect after the hotspot restarts to check its security.")
|
||||
:st.apSecurity==="admin_password"?"Setup WiFi uses this node's admin password."
|
||||
:st.apSecurity==="open_admin_login"?"Setup WiFi is open; web configuration and firmware uploads still require this node's admin password."
|
||||
:st.apSecurity==="open_no_password"?"Setup WiFi is open. This build has no admin password configured."
|
||||
:"Setup WiFi security is unavailable.";
|
||||
var candidate=st.dirty.password||"";
|
||||
$$("[data-admin-ap-hint]").forEach(function(el){
|
||||
el.classList.toggle("hide",!setup||!candidate);
|
||||
el.textContent=adminApEligible(candidate)?"Setup WiFi will use this password."
|
||||
:"WiFi will be open; web configuration and firmware uploads still require this password.";
|
||||
});
|
||||
}
|
||||
function setupPasswordPending(auth){
|
||||
if(st.mode==="setup"&&auth&&auth.apProtected!==undefined){
|
||||
st.apRestartPending=true;st.apPendingProtected=auth.apProtected;updateSetupSecurity();
|
||||
}
|
||||
}
|
||||
// build.sh embeds base[.build][-observer][-channel]-hash, e.g.
|
||||
// v1.16.0.5-observer-beta-dev-a1b2c3d. What identifies a build to a person is
|
||||
// the base, the published build number and the channel -- the -observer variant
|
||||
@@ -758,6 +786,8 @@ function boot(){
|
||||
// (active_slots: 2 without PSRAM, 5 with). Fall back to the runtime array
|
||||
// size for older firmware that doesn't report it.
|
||||
st.mode=s.mode;st.authed=s.auth;st.hasMqtt=!!s.mqtt;st.hasCli=!!s.cli;
|
||||
st.apSecurity=s.ap_security||"unavailable";st.apRestartPending=!!s.ap_restart_pending;
|
||||
delete st.apPendingProtected;updateSetupSecurity();
|
||||
st.streamTerminal=!!s.terminal_stream;st.terminalMax=s.terminal_max_command||541;
|
||||
$("#term-disconnect").classList.toggle("hide",!st.streamTerminal);
|
||||
st.wifiPsk64=!!s.wifi_psk64;
|
||||
@@ -831,6 +861,12 @@ function doLogout(){post("/api/logout",{}).then(function(){++loginEpoch;loginRes
|
||||
function reauthenticateOnce(auth,note,resume,keepView){
|
||||
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);
|
||||
if(st.mode==="setup"&&auth&&auth.apProtected!==undefined&&!keepView){
|
||||
note+=" The setup WiFi may restart. "+(auth.apProtected
|
||||
?"Rejoin it with the new admin password."
|
||||
:"Join it without a WiFi password, then sign in with the new admin password.");
|
||||
}
|
||||
if(auth){auth.attempted=true;auth.password="";auth.view=view;auth.restored=false}
|
||||
st.authed=false;clearInterval(st.statsTimer);st.statsTimer=0;
|
||||
$("#login-note").textContent=note;$("#login-note").classList.remove("hide");
|
||||
@@ -843,7 +879,7 @@ function reauthenticateOnce(auth,note,resume,keepView){
|
||||
loginRequest(password).then(function(x){
|
||||
if(epoch!==loginEpoch)return;
|
||||
btn.disabled=false;
|
||||
if(x.s===200){st.authed=true;loginResume=null;restored()}
|
||||
if(x.s===200){setupPasswordPending(auth);st.authed=true;loginResume=null;restored()}
|
||||
else $("#login-err").textContent=x.j.error||"Sign in with the current admin password";
|
||||
}).catch(function(){if(epoch===loginEpoch){btn.disabled=false;$("#login-err").textContent="Couldn't sign in automatically. Enter the current admin password."}});
|
||||
password="";
|
||||
@@ -854,6 +890,7 @@ function forgetSubmittedPassword(submitted){
|
||||
if(st.dirty.password===password)delete st.dirty.password;
|
||||
$$('[data-k="password"],[data-cfm="password"]').forEach(function(el){if(el.value===password)el.value=""});
|
||||
delete submitted.password;
|
||||
updateSetupSecurity();
|
||||
}
|
||||
|
||||
/* ---------- config load / bind ---------- */
|
||||
@@ -1033,6 +1070,7 @@ function loadConfig(loadCapture){
|
||||
// any values the operator changed meanwhile against the newly loaded
|
||||
// baseline instead of silently replacing their selection or typing.
|
||||
restoreConfigLoadEdits(loadCapture);
|
||||
updateSetupSecurity();
|
||||
updateSaveBar();
|
||||
return true;
|
||||
});
|
||||
@@ -1070,6 +1108,7 @@ document.addEventListener("input",function(ev){
|
||||
// keep duplicate fields (wizard vs app share keys) in sync
|
||||
$$('[data-k="'+el.dataset.k+'"]').forEach(function(o){if(o!==el)setEl(o,elVal(el))});
|
||||
markDirty(el.dataset.k,elVal(el),el);
|
||||
if(el.dataset.k==="password")updateSetupSecurity();
|
||||
}else if(el.dataset.cfm){
|
||||
$$('[data-cfm="'+el.dataset.cfm+'"]').forEach(function(o){if(o!==el)o.value=el.value});
|
||||
}else if(el.dataset.rg){
|
||||
@@ -1287,6 +1326,7 @@ function saveChanges(extra,cb){
|
||||
if(Object.keys(setmap).length===0&&!body.reboot){toast("Nothing to save");return}
|
||||
var reqid=mkReqId();body.reqid=reqid;
|
||||
var auth={password:setmap.password||"",attempted:false,view:$("#v-wizard").classList.contains("hide")?"#v-app":"#v-wizard"};
|
||||
if(auth.password)auth.apProtected=adminApEligible(auth.password);
|
||||
$("#save-btn").disabled=true;
|
||||
post("/api/config",body).then(function(){pollResult(cb,0,0,true,reqid,setmap,auth)})
|
||||
.catch(function(e){
|
||||
@@ -1369,7 +1409,6 @@ function pollResult(cb,errs,idles,acked,reqid,submitted,auth){
|
||||
if(auth.restored){if(!r.reboot||failed)loadConfigSoft();return true}
|
||||
var note=failed?"The admin password changed, but some settings were rejected. The node will not reboot."
|
||||
:r.reboot?"Settings saved. The node is rebooting.":"Settings saved. The admin password changed.";
|
||||
if(st.mode==="setup"&&!r.reboot)note+=" The setup WiFi may restart. Rejoin it with the new admin password if prompted.";
|
||||
reauthenticateOnce(auth,note,function(){pollResult(cb,0,0,true,reqid,submitted,auth)},!!r.reboot&&!failed);
|
||||
return true;
|
||||
}
|
||||
@@ -1426,6 +1465,7 @@ function loadConfigSoft(){ // re-sync origin values without clobbering result ch
|
||||
for(var i=1;i<=st.nslots;i++){refreshSlotFields($("#app-slots"),i);refreshSlotFields($("#wz-slots"),i)}
|
||||
syncPacketFilters();
|
||||
afHint();
|
||||
updateSetupSecurity();
|
||||
updateSaveBar();
|
||||
}).catch(function(){});
|
||||
}
|
||||
@@ -2472,22 +2512,29 @@ $("#term-in").addEventListener("paste",function(ev){
|
||||
var text=cb.getData("text")||"";
|
||||
if(!/[\r\n]/.test(text))return;
|
||||
ev.preventDefault();
|
||||
var cmds=cliParse(text);
|
||||
// Whatever was already typed joins the first pasted line verbatim -- trimming
|
||||
// it would eat the space in a half-typed "set " and silently fuse the words.
|
||||
var carry=this.value;
|
||||
if(carry.trim()&&cmds.length){cmds[0]=carry+cmds[0];this.value=""}
|
||||
if(carry.trim())text=carry+text;
|
||||
var cmds=cliParse(text);
|
||||
if(carry.trim()&&cmds.length)this.value="";
|
||||
if(!cmds.length){cliEcho("sys","Nothing to run -- that paste held no commands.");return}
|
||||
if(cmds.length===1){this.value=cmds[0];cliSug();return}
|
||||
cliHideSug();
|
||||
cliConfirm(cmds);
|
||||
});
|
||||
function cliCommandLine(raw){
|
||||
var line=raw.replace(/^\s+/,"");
|
||||
// Password arguments are byte-significant, including their trailing spaces.
|
||||
return /^password /i.test(line)?line:line.trim();
|
||||
}
|
||||
function cliParse(text){
|
||||
var out=[];
|
||||
text.split(/\r\n|\r|\n/).forEach(function(raw){
|
||||
var l=raw.trim();
|
||||
var l=raw.replace(/^\s+/,"");
|
||||
if(!l||l.charAt(0)==="#")return; // blanks and comments
|
||||
l=l.replace(/^(?:[>$]|meshcore\s*[>$#])\s+/,""); // tolerate a pasted transcript
|
||||
l=cliCommandLine(l);
|
||||
if(l)out.push(l);
|
||||
});
|
||||
return out;
|
||||
@@ -2510,7 +2557,7 @@ var CLI_RISK=[
|
||||
[/^set espnow\.channel\s/,"changes the shared ESP-NOW/WiFi channel after reboot -- every ESP-NOW peer and the router must match"],
|
||||
[/^set radio\s/,"changes radio parameters -- a wrong value takes this node off the air"],
|
||||
[/^set freq\s/,"changes the frequency -- a wrong value takes this node off the air"],
|
||||
[/^password\s/,"changes the admin password"],
|
||||
[/^password\s/i,"changes the admin password"],
|
||||
[/^set prv\.key\s/,"replaces this node's identity"]
|
||||
];
|
||||
function cliRisks(cmds){
|
||||
@@ -2582,7 +2629,7 @@ function cliConfirm(cmds){
|
||||
stream back, so a long sequence fills the window as it executes. */
|
||||
function cliSubmit(){
|
||||
if(cli.busy)return;
|
||||
var inp=$("#term-in"),line=inp.value.trim();
|
||||
var inp=$("#term-in"),line=cliCommandLine(inp.value);
|
||||
cliHideSug();
|
||||
if(!line)return;
|
||||
cliPush(line);inp.value="";
|
||||
@@ -2621,7 +2668,9 @@ function cliRun(cmds){
|
||||
function cliPasswordContext(cmds){
|
||||
var password="";
|
||||
cmds.forEach(function(c){if(/^password /i.test(c))password=c.slice(9)});
|
||||
return{password:password,attempted:false,required:false,view:"#v-app",output:[]};
|
||||
var auth={password:password,attempted:false,required:false,view:"#v-app",output:[]};
|
||||
if(password)auth.apProtected=adminApEligible(password);
|
||||
return auth;
|
||||
}
|
||||
|
||||
// Stream replies for every role. Companion sessions also own recipient/login
|
||||
@@ -2754,7 +2803,9 @@ function cliPoll(reqid,cmds,from,status,errs,idles,auth){
|
||||
// `ok` is advisory; the node's own convention is authoritative, and only
|
||||
// failure has a fixed shape there ("Err"/"ERR:"/"Error:").
|
||||
var ok=(x.ok!=null)?x.ok:!/^\s*err/i.test(x.reply||"");
|
||||
if(!auth.attempted&&ok&&/^password /i.test(cmds[from+i]||""))auth.password=cmds[from+i].slice(9);
|
||||
if(!auth.attempted&&ok&&/^password /i.test(cmds[from+i]||"")){
|
||||
auth.password=cmds[from+i].slice(9);auth.apProtected=adminApEligible(auth.password);
|
||||
}
|
||||
// Getters answer "> value" -- that leading marker is the serial console's
|
||||
// way of setting a value apart, and here it collides with the prompt glyph
|
||||
// that means "you typed this". Drop it; the colour already says "reply".
|
||||
@@ -2789,8 +2840,8 @@ function cliAfter(cmds,r,auth){
|
||||
auth=auth||cliPasswordContext(cmds);
|
||||
var touched=false;
|
||||
cmds.forEach(function(c){
|
||||
if(/^password\s/.test(c))cli.pwd=true;
|
||||
if(/^(set|password)\s/.test(c))touched=true;
|
||||
if(/^password\s/i.test(c))cli.pwd=true;
|
||||
if(/^set\s/.test(c)||/^password\s/i.test(c))touched=true;
|
||||
});
|
||||
// Same prefix dispatch as the risk list: `poweroff now` powers off too.
|
||||
var reauth=r&&r.reauth_required&&!(auth&&auth.restored);
|
||||
|
||||
Reference in new issue
Block a user