Preserve admin password input and clarify reconnect guidance

This commit is contained in:
mikecarper committed 2026-10-18 13:45:13 -07:00
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
View File
@@ -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
+10 -7
View File
@@ -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.
+3 -1
View File
@@ -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");
}
}
}
File diff suppressed because it is too large. Load diff
+23 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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"));
}
+3 -1
View File
@@ -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");
+4 -1
View File
@@ -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);
}
+39
View File
@@ -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()
+176
View File
@@ -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")
+2 -2
View File
@@ -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)
+1
View File
@@ -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
View File
@@ -226,13 +226,14 @@ body.tab-cli{padding-bottom:0}
aria-label="Open console">&gt;_</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);