Allow local CLI maintenance across direct connections

This commit is contained in:
mikecarper committed 2026-09-08 21:44:57 -07:00
1 parent 001b4985f7
commit 06c51fdef5
39 files changed
+3338 -2312

No files matched your search

+3 -2
View File
@@ -75,8 +75,9 @@ def requirements(platform, defines, target):
parts["mqtt_connections_buffers"] = 24576
if "ENABLE_OTA" in defines:
parts["ota_source_scratch"] = 8192
if (companion and "WIFI_SSID" in defines
and "WEBCONFIG_DISABLED" not in defines):
if ("WEBCONFIG_DISABLED" not in defines
and ((companion and "WIFI_SSID" in defines)
or (not companion and "ADMIN_PASSWORD" in defines))):
parts["browser_terminal_session"] = 2048
if "BOARD_HAS_PSRAM" not in defines:
# Larger replies grow on demand only while preserving 32 KiB
+5 -7
View File
@@ -41,7 +41,8 @@ EXPECTED_FAILURES = {
# Commands that change the node out from under the audit.
SKIP = {"reboot", "clkreboot", "poweroff", "shutdown", "erase", "start ota",
"stop webconfig", "ota update", "start webconfig", "start webconfig ap"}
"stop webconfig", "ota update", "start webconfig", "start webconfig ap",
"log", "get acl"} # Listings use /api/terminal, not this bounded API audit.
def table():
@@ -80,8 +81,6 @@ NOT_OFFERED = {
"tls.bundletest", # TLS debugging, not an operator command
"start ota", # binds port 80, which the portal is already using
"clock sync", # takes its time from the caller; a web request has none
"log", # streams to Serial and stalls the radio ("log start" is offered)
"get acl", # streams to Serial, returns nothing
}
@@ -121,10 +120,9 @@ ROUND_TRIPS = [
("set path.hash.mode 2", "get path.hash.mode", "2"),
("set mqtt.iata den", "get mqtt.iata", "DEN"),
("set radio.rxps 70000 60000", "get radio.rxps", "on,70000,60000"),
# Secret reads are masked back down for an HTTP caller, in CommonCLI's own
# words for a non-serial one (wcIsSecretReadCommand).
("set guest.password hunter2", "get guest.password", "******** (serial only)"),
("set wifi.pwd hunter2", "get wifi.pwd", "******** (serial only)"),
# Explicit authenticated LAN getters have local-connection privileges.
("set guest.password hunter2", "get guest.password", "hunter2"),
("set wifi.pwd hunter2", "get wifi.pwd", "hunter2"),
]
+10 -24
View File
@@ -358,9 +358,9 @@ def apply_set(cfg, key, val):
return True, "OK"
if key == "prv.key":
# write-only by design: the identity goes in, nothing reads it back
if not _hex64(val):
return False, "Error: private key must be 64 hex characters"
if len(val) != 128 or not all(c in "0123456789abcdefABCDEF" for c in val):
return False, "Error: private key must be 128 hex characters"
cfg["identity_private_key"] = val
return True, "OK - identity restored, reboot to apply"
if key == "mqtt.owner":
@@ -518,9 +518,8 @@ CLI_UNAVAILABLE = [
"Run it from the serial console, or use `ota update`."),
("clock sync", True, "clock sync takes its time from the caller, which a web request "
"has no way to supply. Use `time <epoch-seconds>` instead."),
("log", False, "log writes the packet log to the serial console, not here, and "
"blocks the radio while it does. Use `log start` / `log stop`."),
("get acl", False, "get acl writes to the serial console, not here."),
("log", False, "Use /api/terminal for streamed listings."),
("get acl", False, "Use /api/terminal for streamed listings."),
]
@@ -534,17 +533,6 @@ def cli_unavailable(cmd):
# Failure replies CommonCLI emits that do NOT start with "Err" -- the shapes that
# made a naive prefix test call them success. Mirrors
# WebConfigBatch::cliReplyIsFailure.
def cli_reads_secret(cmd):
"""Commands that READ a secret. CommonCLI gates these on the caller being
the serial console; the portal is not, so the value is masked here the way
CommonCLI masks it for remote callers. Mirrors wcCliReadsSecret()."""
if not cmd.startswith("get "):
return False
key = cmd[4:].strip()
return key in ("prv.key", "guest.password", "alert.psk", "bridge.secret") \
or is_secret_key(key)
def cli_reply_is_failure(reply):
if not reply:
return False
@@ -560,6 +548,7 @@ GETTERS = {
"sf": lambda c: str(c["radio"]["sf"]),
"cr": lambda c: str(c["radio"]["cr"]),
"public.key": lambda c: "a1b2c3d4" * 8,
"prv.key": lambda c: c.get("identity_private_key", "a5" * 64),
"wifi.status": lambda c: (
"SSID: %s\nIP: 192.168.1.42\nRSSI: -58 dBm\nUptime: %dm"
% (c["wifi"]["ssid"] or "(not set)", int(time.time() - ST.start) // 60)),
@@ -613,10 +602,11 @@ def _cli_get_value(cfg, key):
if key in GETTERS:
val = GETTERS[key](cfg)
return (True, val) if val is not None else (False, "Error: unsupported")
if key == "password":
return True, ADMIN_PASSWORD
if is_secret_key(key):
# The serial console prints these; the portal is reachable over the LAN,
# so it masks them the same way /api/config does.
return True, SENTINEL if cli_read_key(cfg, key) else "(not set)"
# Explicit LAN CLI getters return values; /api/config remains masked.
return True, cli_read_key(cfg, key) or "(not set)"
val = cli_read_key(cfg, key)
if val is None:
return False, "??: %s" % key # CommonCLI::handleGetCmd fallthrough
@@ -1035,10 +1025,6 @@ class Handler(BaseHTTPRequestHandler):
if cmd.startswith("password "):
reply = "OK" # never echo the new password back
ST.admin_pwd_set = True
elif cli_reads_secret(cmd):
val = reply[2:] if reply.startswith("> ") else reply
reply = ("> (not set)" if val in ("", "(not set)")
else "> ******** (serial only)")
ok = not cli_reply_is_failure(reply)
# Only writes gate the reboot, and only on the "OK" convention every
# setter keeps (WebConfigBatch::cliReplyGatesReboot).