diff --git a/.gitignore b/.gitignore index d19ed87e..699b2824 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,10 @@ platformio.local.ini .cursor/* .claude/* .cursorrules -# Throwaway build worktrees; committing one adds a stray gitlink that makes every -# CI checkout warn "No url found for submodule path ... in .gitmodules". +# Worktrees checked out inside the repo. Committing one adds a stray gitlink +# that makes every CI checkout warn "No url found for submodule path ... in +# .gitmodules", and leaves `git status` permanently dirty so the next `git add +# -A` re-commits it. .wt-* covers the hand-made ones; .build-wt-* the CI ones. .build-wt-*/ +.wt-*/ scripts/__pycache__/* diff --git a/.wt-station-g3-prod b/.wt-station-g3-prod deleted file mode 160000 index bfc43e94..00000000 --- a/.wt-station-g3-prod +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfc43e94f8495307346c16e9f8014617fd2e5cbe diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index efb6532b..db4878c4 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1438,7 +1438,7 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { } if (!_webconfig) { _webconfig = new WebConfigServer(&_prefs, _cli.getObserverPrefs(), this, - self_id.pub_key, getFirmwareVer(), getRole(), + self_id.pub_key, getFirmwareVer(), getBuildDate(), getRole(), _cli.getBoard()->getManufacturerName()); } if (force_ap) { diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index f2e447c6..546fed45 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1263,7 +1263,7 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { } if (!_webconfig) { _webconfig = new WebConfigServer(&_prefs, _cli.getObserverPrefs(), this, - self_id.pub_key, getFirmwareVer(), getRole(), + self_id.pub_key, getFirmwareVer(), getBuildDate(), getRole(), _cli.getBoard()->getManufacturerName()); } if (force_ap) { diff --git a/scripts/generate_webconfig_html.py b/scripts/generate_webconfig_html.py index 143687d3..18afaab3 100644 --- a/scripts/generate_webconfig_html.py +++ b/scripts/generate_webconfig_html.py @@ -14,39 +14,55 @@ # hashes two small files and returns, so running it from esp32_base on every # ESP32 build is negligible even for targets that don't compile the portal. # +# Comments and indentation are stripped before compressing (see strip_source). +# The source page is heavily commented by house style and none of it is worth +# flash, so the page ships smaller than it reads. +# # Output: src/helpers/esp32/WebConfigHtml.h # WEBCONFIG_HTML_GZ[] - gzipped page (PROGMEM) # WEBCONFIG_HTML_GZ_LEN - byte length # WEBCONFIG_HTML_ETAG - quoted strong ETag (sha256 prefix of the gz body) +# +# Runnable outside SCons to inspect exactly what gets shipped: +# python3 scripts/generate_webconfig_html.py --emit /tmp/shipped.html +# or served directly by the mock backend with its --minify flag. import gzip import hashlib import os import sys -Import("env") # noqa: F821 +try: + Import("env") # noqa: F821 +except NameError: + pass # running standalone (--emit), not as a PIO extra_script SOURCE = os.path.join("webui", "index.html") OUTPUT = os.path.join("src", "helpers", "esp32", "WebConfigHtml.h") # __file__ is not defined inside PIO/SCons-executed extra_scripts SCRIPT = os.path.join("scripts", "generate_webconfig_html.py") +MINIFIER = os.path.join("scripts", "webconfig_minify.py") HASH_MARKER = "// build-inputs-sha256: " +sys.path.insert(0, os.path.join(os.getcwd(), "scripts")) +from webconfig_minify import check_stripped, strip_source # noqa: E402 + def status(msg): sys.stderr.write("WebConfig HTML: %s\n" % msg) def content_hash(): - # Hash the source page and this generator so any change to either forces a - # regenerate, independent of file timestamps. + # Hash the source page, this generator and the minifier so any change to + # any of them forces a regenerate, independent of file timestamps. h = hashlib.sha256() with open(SOURCE, "rb") as f: h.update(f.read()) - if os.path.isfile(SCRIPT): - h.update(b"\0") - with open(SCRIPT, "rb") as f: - h.update(f.read()) + for path in (SCRIPT, MINIFIER): + if os.path.isfile(path): + h.update(b"\0") + with open(path, "rb") as f: + h.update(f.read()) return h.hexdigest() @@ -63,17 +79,37 @@ def stored_hash(): return None +def shipped_page(): + """The exact bytes the device serves: the source page, stripped.""" + with open(SOURCE, "r", encoding="utf-8") as f: + raw = f.read() + stripped = strip_source(raw) + problem = check_stripped(raw, stripped) + if problem: + status("ERROR: comment stripping corrupted the page (%s)" % problem) + sys.exit(2) + return raw, stripped + + def main(): if not os.path.isfile(SOURCE): status("ERROR: %s not found" % SOURCE) sys.exit(2) + if "--emit" in sys.argv: + dest = sys.argv[sys.argv.index("--emit") + 1] + src, stripped = shipped_page() + with open(dest, "w", encoding="utf-8") as f: + f.write(stripped) + status("%s -> %s (%d -> %d bytes)" % (SOURCE, dest, len(src), len(stripped))) + return + src_hash = content_hash() if os.path.isfile(OUTPUT) and stored_hash() == src_hash: return - with open(SOURCE, "rb") as f: - raw = f.read() + src, stripped = shipped_page() + raw = stripped.encode("utf-8") # mtime=0 keeps the gzip output (and therefore the ETag) deterministic gz = gzip.compress(raw, compresslevel=9, mtime=0) @@ -100,7 +136,8 @@ def main(): with open(OUTPUT, "w") as f: f.write("\n".join(lines)) - status("%s -> %s (%d bytes raw, %d bytes gzipped)" % (SOURCE, OUTPUT, len(raw), len(gz))) + status("%s -> %s (%d bytes source, %d stripped, %d gzipped)" + % (SOURCE, OUTPUT, len(src.encode("utf-8")), len(raw), len(gz))) main() diff --git a/scripts/webconfig_cli_audit.py b/scripts/webconfig_cli_audit.py new file mode 100644 index 00000000..3cd24db4 --- /dev/null +++ b/scripts/webconfig_cli_audit.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Check the portal terminal's command table against the mock backend. + +Autocomplete in webui/index.html carries its own list of commands. Nothing ties +that list to what a node actually answers, so it can quietly drift into offering +commands that do not exist — or, more often here, the mock can lag the table and +make a perfectly real command look broken. + +This drives every command the table offers through /api/cli and reports the ones +that come back an error, so the two stay honest about each other. + + python3 scripts/webconfig_mock_server.py --port 8137 & + python3 scripts/webconfig_cli_audit.py + +Exits non-zero if anything fails that is not in EXPECTED_FAILURES. Stdlib only. +""" + +import json +import os +import re +import secrets +import sys +import time +import urllib.error +import urllib.request + +BASE = os.environ.get("WEBCONFIG_MOCK", "http://localhost:8137") +HERE = os.path.dirname(os.path.abspath(__file__)) +INDEX_HTML = os.path.join(HERE, "..", "webui", "index.html") + +# Errors that are the correct answer, not a gap. +EXPECTED_FAILURES = { + # Runtime-gated on the real device by Board::canControlLoRaFemLna(); the + # command exists in every build and the board answers for itself. The mock + # board is a Heltec V3, which has no front-end module. + "get radio.fem.rxgain": "unsupported", + "set radio.fem.rxgain on": "unsupported", + # Guarded by the firmware the same way when no alert PSK is configured. + "alert test": "not configured", +} + +# 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"} + + +def table(): + """The commands autocomplete offers, read straight out of the page.""" + html = open(INDEX_HTML, encoding="utf-8").read() + + def section(start, end): + return html[html.index(start):html.index(end)] + + verbs = re.findall(r'\["([^"]+)","', section("var CLI_VERBS=", "var CLI_KEYS=")) + keys = re.findall(r'\["([^"]+)","(?:[^"\\]|\\.)*",(\d)', + section("var CLI_KEYS=", "var CLI_SLOT=")) + fields = re.findall(r'\["(\w+)","', section("var CLI_SLOT=", "var CLI_TYPES=")) + + gets = ["get " + k for k, mode in keys if mode != "2"] + gets += ["get mqtt%d.%s" % (n, f) for n in (1, 3) for f in fields] + # Verbs taking an argument need a value the node will accept; those are + # covered by the round-trip probes below rather than guessed at here. + plain = [v for v in verbs if not v.endswith(" ") and v not in SKIP] + return gets + plain + + +# Where top-level commands are implemented. MyMesh handles a few before +# delegating to CommonCLI, which is exactly how discover.* stayed missing from +# the table for so long: grepping CommonCLI alone does not see them. +COMMAND_SOURCES = [ + "src/helpers/CommonCLI.cpp", + "src/helpers/CommonCLI_Observer.cpp", + "examples/simple_repeater/MyMesh.cpp", +] + +# Firmware commands the table deliberately does not offer. Everything below +# except tls.bundletest is also rejected by /api/cli (wcCliUnavailable), so the +# portal never pretends to run something it cannot. +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 +} + + +def firmware_commands(): + """Top-level command literals the firmware dispatches on.""" + found = set() + for rel in COMMAND_SOURCES: + path = os.path.join(HERE, "..", rel) + try: + src = open(path, encoding="utf-8").read() + except OSError: + continue + for lit in re.findall(r'(?:mem|str)n?cmp\(\s*command\s*,\s*"([^"]+)"', src): + found.add(lit.strip()) + return found - NOT_OFFERED +ROUND_TRIPS = [ + ("set radio.watchdog 30", "get radio.watchdog", "30"), + ("set dutycycle 25", "get dutycycle", "25.0"), + ("set alert.mqtt on", "get alert.mqtt", "on"), + ("set bridge.source tx", "get bridge.source", "tx"), + ("set mqtt.neighbors on", "get mqtt.neighbors", "on"), + ("set path.hash.mode 2", "get path.hash.mode", "2"), + ("set mqtt.iata den", "get mqtt.iata", "DEN"), + # 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)"), +] + + +class Client: + def __init__(self, base): + self.base = base + r = self._open("/api/login", b'{"password":"password"}') + self.cookie = r.headers["Set-Cookie"].split(";")[0] + # The node caps a sequence at MAX_BATCH and reports it; chunk to match + # rather than hardcoding a number that drifts when the slot is resized. + self.max_cmds = json.load(self._open("/api/status")).get("max_cmds", 24) + + def _open(self, path, data=None): + headers = {"Content-Type": "application/json"} + if getattr(self, "cookie", None): + headers["Cookie"] = self.cookie + return urllib.request.urlopen(urllib.request.Request( + self.base + path, data=data, headers=headers, + method="POST" if data is not None else "GET")) + + def run(self, cmds): + """[(command, result)]. The node never echoes the command back — it may + carry a secret — so results pair with what was sent, by index.""" + out = [] + for i in range(0, len(cmds), self.max_cmds): + chunk = cmds[i:i + self.max_cmds] + results = self._sequence(chunk) + if len(results) != len(chunk): + sys.exit("node returned %d results for %d commands" % (len(results), len(chunk))) + out += list(zip(chunk, results)) + return out + + def _sequence(self, cmds): + reqid = secrets.token_hex(8) + body = json.dumps({"reqid": reqid, "cmds": cmds}).encode() + for _ in range(200): # the executor frees itself in time + try: + self._open("/api/cli", body) + break + except urllib.error.HTTPError as e: + if e.code != 409: + raise + time.sleep(0.5) + # Results stream and page, so keep reading from a cursor until the node + # says done — "done" arrives only once every result has been handed over. + out = [] + while True: + r = json.load(self._open("/api/cli/result?reqid=%s&from=%d" % (reqid, len(out)))) + out += r.get("results", []) + if r["state"] == "done": + return out + time.sleep(0.05) + + +def main(): + try: + cli = Client(BASE) + except OSError as e: + sys.exit("cannot reach the mock at %s (%s)\n" + "start it with: python3 scripts/webconfig_mock_server.py --port 8137" % (BASE, e)) + + failures = [] + + cmds = table() + unexpected = [] + for cmd, res in cli.run(cmds): + if res["ok"]: + continue + want = EXPECTED_FAILURES.get(cmd) + if want and want in res["reply"]: + continue + unexpected.append((cmd, res["reply"])) + print("commands offered by autocomplete : %d" % len(cmds)) + print("answered : %d" % (len(cmds) - len(unexpected))) + print("sequence cap reported by the node: %d" % cli.max_cmds) + for cmd, reply in unexpected: + print(" FAIL %-30s %s" % (cmd, reply)) + failures += unexpected + + # The reverse direction: a command the firmware implements but the table + # never offers is invisible to the check above, because the check only ever + # drives what the table already knows about. + offered = " ".join(cmds) + " " + " ".join( + re.findall(r'\["([^"]+)","', open(INDEX_HTML, encoding="utf-8").read())) + missing = sorted(c for c in firmware_commands() if c not in offered) + print("\nfirmware commands not in the table: %d" % len(missing)) + for c in missing: + print(" MISSING %s" % c) + failures += [(c, "not offered by autocomplete") for c in missing] + + results = cli.run([c for probe in ROUND_TRIPS for c in probe[:2]]) + print("\nround-trips : %d" % len(ROUND_TRIPS)) + for i, (setc, getc, want) in enumerate(ROUND_TRIPS): + setr, getr = results[i * 2][1], results[i * 2 + 1][1] + # `get` answers "> value"; compare the value, as the terminal displays it + got = re.sub(r"^>\s?", "", getr["reply"]) + if setr["ok"] and got == want: + continue + print(" FAIL %-30s got %r, wanted %r (set: %s)" + % (getc, got, want, setr["reply"])) + failures.append((getc, got)) + + # Commands the portal refuses must be refused clearly, not run and fudged. + print("\nrefused with a reason : ", end="") + refused = [] + for cmd in sorted(NOT_OFFERED - {"tls.bundletest"}): + try: + cli._sequence([cmd]) + refused.append((cmd, "was accepted, expected a 400")) + except urllib.error.HTTPError as e: + body = json.load(e) if e.code == 400 else {} + if e.code != 400 or not body.get("error"): + refused.append((cmd, "HTTP %d, expected 400 with a reason" % e.code)) + print("%d/%d" % (len(NOT_OFFERED) - 1 - len(refused), len(NOT_OFFERED) - 1)) + for cmd, why in refused: + print(" FAIL %-30s %s" % (cmd, why)) + failures += refused + + print("\n%s" % ("FAILED: %d" % len(failures) if failures else "all clear")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/webconfig_minify.py b/scripts/webconfig_minify.py new file mode 100644 index 00000000..0f7e1746 --- /dev/null +++ b/scripts/webconfig_minify.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Comment/indentation stripping for webui/index.html. + +Shared by the build-time generator (which embeds the stripped page in flash) +and the mock backend's --minify flag (which serves it), so what you test in a +browser is byte-for-byte what the device ships. A second implementation would +only drift. + +Stdlib only; imported from a PIO extra_script, so it must stay side-effect free. +""" + +# Comment openers/closers per region of the page. The region is tracked so a +# `")], + "css": [("/*", "*/")], + "js": [("/*", "*/")], +} + + +def strip_source(text): + """Drop comments, indentation and blank lines from the page. + + Deliberately line-based and conservative. Only a comment that *starts* its + own line is removed, so a `//` inside a URL or a `/*` inside a regex is + never mistaken for one; trailing comments survive, which costs a little + flash and removes the entire class of "the minifier ate a string" bug. + + Line breaks are preserved. That keeps JS statement boundaries exactly as + written (no ASI surprises) and keeps the single collapsed space a newline + contributes between HTML inline elements. + """ + out, mode, closer = [], "html", None + for line in text.split("\n"): + s = line.strip() + + if closer is not None: # inside a multi-line comment + at = s.find(closer) + if at < 0: + continue + s = s[at + len(closer):].strip() # code may follow the close + closer = None + + if not s: + continue + + for opener, close in _COMMENTS[mode]: + if not s.startswith(opener): + continue + if s.endswith(close) and len(s) > len(opener) + len(close) - 1: + s = "" # the whole line is a comment + elif close not in s[len(opener):]: + closer = close # ... and it continues below + s = "" + break # else code follows the close + if not s: # on this line: leave it be + continue + + if mode == "js" and s.startswith("//"): + continue + + out.append(s) + + low = s.lower() + if mode == "html" and "" in low or "" in low): + mode = "html" + + return "\n".join(out) + "\n" + + +def check_stripped(raw, stripped): + """Guard against a stripper bug silently shipping a broken portal to the + fleet. Returns a reason string when the output looks wrong, else None.""" + for tag in ("", "", ""): + if raw.count(tag) != stripped.count(tag): + return "%s count changed" % tag + if len(stripped) < len(raw) * 0.5: + return "output shrank by more than half (%d -> %d)" % (len(raw), len(stripped)) + # Only comments and whitespace may go, so no structural token may appear + # that the source did not already have. + for token in ("{", "}", "(", ")", " raw.count(token): + return "gained a %s" % token + return None diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index 86885ac4..c38d804e 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -9,6 +9,11 @@ pending -> done result polling, aggregate-success reboot gating, secret masking So the browser drives the actual portal JS (wizard, save/poll/reqid, effective value handling, reboot overlay, stats, scan) against realistic responses. +/api/cli is the CLI terminal's backend and has no firmware counterpart yet: it +is the proposed contract (202 + reqid, streamed per-command results) executed +against a CommonCLI-shaped interpreter, so the terminal UI can be designed +against realistic single- and multi-line replies before any of it goes on-device. + It does NOT run the C++ handlers (that's what test/ gtest covers) or the AsyncTCP transport — it's a frontend + contract harness. @@ -28,6 +33,7 @@ import json import os import re import secrets +import sys import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -36,6 +42,18 @@ from urllib.parse import parse_qs, urlsplit HERE = os.path.dirname(os.path.abspath(__file__)) INDEX_HTML = os.path.join(HERE, "..", "webui", "index.html") +sys.path.insert(0, HERE) +# The build-time comment stripper, shared so --minify serves byte-for-byte what +# the generator embeds rather than a second implementation that could drift. +from webconfig_minify import strip_source # noqa: E402 + +MINIFY = False +# Overridable with --fw-version to exercise the console's channel labelling: +# v1.16.0.5-observer-a1b2c3d release +# v1.16.0.5-observer-beta-dev-a1b2c3d dev +# v1.16.0 local build, no OTA +FW_VERSION = "v1.16.0.5-observer-a1b2c3d" + SENTINEL = "********" ADMIN_PASSWORD = "password" # matches the default ADMIN_PASSWORD build flag BATCH_PENDING_SECS = 0.8 # how long POST->done takes, to exercise polling @@ -100,8 +118,23 @@ def default_config(setup_mode): "interval": 5, "timezone": "MST7MDT,M3.2.0,M11.1.0", "timezone_offset": -7, "ntp": "pool.ntp.org", "owner": "", "email": "", "snmp": False, "snmp_community": "public", + "neighbors": False, "neighbors_interval": 24, "slots": [_slot() for _ in range(6)], }, + # Settings the CLI reaches but no portal form does, so they are absent + # from /api/config (see config_json) and live only here. Without them + # the terminal answers "unknown config key" for perfectly real commands. + "cli": { + "radio.watchdog": 0, "int.thresh": 0, "agc.reset.interval": 0, + "direct.txdelay": 0.0, "multi.acks": 0, "allow.read.only": False, + "path.hash.mode": 0, "owner.info": "", "guest.password": "", + "adc.multiplier": 1.0, + "alert": False, "alert.psk": "", "alert.hashtag": "", + "alert.region": "", "alert.interval": 15, + "alert.mqtt": False, "alert.wifi": False, + "bridge.enabled": False, "bridge.source": "rx", "bridge.baud": 115200, + "bridge.delay": 0, "bridge.channel": 0, "bridge.secret": "", + }, } @@ -122,6 +155,8 @@ class State: self.start = time.time() self.session = None # cookie token when logged in (LAN mode) self.batch = {"state": "idle"} + self.cli = {"state": "idle"} # deferred CLI sequence, see /api/cli + self.admin_pwd_set = False # satisfies the initial-setup invariant self.scan_started = None # ---- auth ------------------------------------------------------------- @@ -137,6 +172,7 @@ class State: # ---- config serialization (masks secrets, like handleConfigGet) ------- def config_json(self): c = copy.deepcopy(self.cfg) + c.pop("cli") # CLI-only settings: not part of this contract c["wifi"]["pwd"] = SENTINEL if self.cfg["wifi"]["pwd"] else "" for s in c["mqtt"]["slots"]: s["password"] = SENTINEL if s["password"] else "" @@ -149,9 +185,14 @@ class State: "auth": authed, "needs_setup": self.cfg["wifi"]["ssid"] == "", "name": self.cfg["radio"]["name"], "node_id": "a1b2c3d4e5f60718", - "fw": "v1.7.1-mock", "role": "Repeater", "board": "Heltec V3 (mock)", + # Shaped like build.sh's EMBEDDED_VERSION_STRING + # (base[.build][-observer][-channel]-hash) so the console's channel + # labelling is exercised against a real version, not "v1.x-mock". + "fw": FW_VERSION, "build_date": "6 Jun 2026", + "role": "Repeater", "board": "Heltec V3 (mock)", "uptime_s": int(time.time() - self.start), "runtime_slots": 6, "max_slots": 6, "active_slots": self.active_slots, + "max_cmds": CLI_MAX_CMDS, } @@ -162,13 +203,15 @@ class State: BOOL_KEYS = {"cad": ("radio", "cad"), "radio.rxgain": ("radio", "rxgain"), "repeat": ("radio", "repeat"), "mqtt.status": ("mqtt", "status"), "mqtt.packets": ("mqtt", "packets"), "mqtt.raw": ("mqtt", "raw"), - "mqtt.rx": ("mqtt", "rx"), "snmp": ("mqtt", "snmp")} + "mqtt.rx": ("mqtt", "rx"), "snmp": ("mqtt", "snmp"), + "mqtt.neighbors": ("mqtt", "neighbors")} INT_KEYS = {"tx": ("radio", "tx"), "flood.max": ("radio", "flood_max"), "flood.max.advert": ("radio", "flood_max_advert"), "flood.max.unscoped": ("radio", "flood_max_unscoped"), "advert.interval": ("radio", "advert_interval"), "flood.advert.interval": ("radio", "flood_advert_interval"), "mqtt.interval": ("mqtt", "interval"), + "mqtt.neighbors.interval": ("mqtt", "neighbors_interval"), "timezone.offset": ("mqtt", "timezone_offset")} FLOAT_KEYS = {"lat": ("radio", "lat"), "lon": ("radio", "lon"), "af": ("radio", "af"), "rxdelay": ("radio", "rxdelay"), @@ -180,6 +223,16 @@ STR_KEYS = {"name": ("radio", "name"), "wifi.ssid": ("wifi", "ssid"), "snmp.community": ("mqtt", "snmp_community"), "mqtt.tx": ("mqtt", "tx")} SECRET_STR_KEYS = {"wifi.pwd": ("wifi", "pwd")} +# The CLI-only settings, typed the same way so apply_set/cli_read_key reach them +# through the existing lookups rather than a parallel code path. +for _k, _v in default_config(False)["cli"].items(): + _t = {bool: BOOL_KEYS, int: INT_KEYS, float: FLOAT_KEYS, str: STR_KEYS}[type(_v)] + _t[_k] = ("cli", _k) +SECRET_STR_KEYS.update({k: ("cli", k) for k in + ("guest.password", "alert.psk", "bridge.secret")}) +for _k in SECRET_STR_KEYS: + STR_KEYS.pop(_k, None) + def _hex64(v): return len(v) == 64 and all(c in "0123456789abcdefABCDEF" for c in v) @@ -200,6 +253,28 @@ def apply_set(cfg, key, val): ADMIN_PASSWORD = val return True, "OK" + if key == "radio.fem.rxgain": + return False, "Error: unsupported" # no FEM on the mock board, see GETTERS + + if key == "dutycycle": + try: + dc = float(val) + except ValueError: + return False, "Error: expected a number" + if not 0 < dc <= 100: + return False, "Error, must be 1-100" + cfg["radio"]["af"] = 100.0 / dc - 1 # the CLI stores it as airtime_factor + return True, "OK" + + if key in ("freq", "bw", "sf", "cr"): + # single-component radio setters, reachable from the CLI but not from + # the form batch (which always sends the whole `radio` combo) + try: + cfg["radio"][key] = int(val) if key in ("sf", "cr") else float(val) + except ValueError: + return False, "Error: expected a number" + return True, "OK - reboot to apply" + if key == "radio": try: f, bw, sf, cr = val.split(",") @@ -220,6 +295,12 @@ def apply_set(cfg, key, val): cfg["mqtt"]["iata"] = val.upper() 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" + return True, "OK - identity restored, reboot to apply" + if key == "mqtt.owner": if val == "": cfg["mqtt"]["owner"] = "" @@ -259,7 +340,11 @@ def apply_set(cfg, key, val): sec, f = STR_KEYS[key] cfg[sec][f] = val return True, "OK" - return True, "OK" # unknown-but-allowlisted: accept (mock is lenient here) + # Strict fallthrough: this function is the single authority on what can be + # set, for the batch and the CLI alike. Accepting unknown keys here once hid + # the fact that the CLI could not reach `dutycycle` or `radio.fem.rxgain`. + # Verbatim shape from CommonCLI::handleSetCmd's fallthrough. + return False, "unknown config: %s" % key # Payload-type names accepted alongside the decimal form. Mirrors @@ -337,7 +422,270 @@ def apply_slot_set(cfg, idx, field, val): def is_secret_key(key): - return key == "wifi.pwd" or bool(re.match(r"^mqtt[1-6]\.(password|token)$", key)) + # The serial console prints these back; the portal is reachable over the + # LAN, so it masks them in `get` replies the way /api/config already does. + return key in SECRET_STR_KEYS or bool(re.match(r"^mqtt[1-6]\.(password|token)$", key)) + + +# --------------------------------------------------------------------------- +# CLI command execution (backs /api/cli), mirroring CommonCLI enough to give +# the terminal UI realistic single- and multi-line replies. +# +# The portal's `set` batch is allowlisted (WebConfigKeys.h) because it is driven +# by form fields; the CLI is deliberately NOT, since its whole point is reaching +# the same surface the serial console reaches. Auth is the boundary — exactly +# as it is for the serial console and for remote admin over the mesh. +# --------------------------------------------------------------------------- +# MAX_BATCH in WebConfigServer.h: the CLI shares the config batch's fixed slot, +# so this is the real cap, reported to the page as status.max_cmds. +CLI_MAX_CMDS = 24 +CLI_RESULT_PAGE = 8 # WebConfigBatch::kCliResultPage +CLI_CMD_SECS = 0.25 # simulated per-command execution time +# Board::reboot() does not return, so the firmware answers `reboot` itself and +# arms the deferred reboot once results have been read (see wcIsDeferredReboot). +CLI_DEFERRED_REBOOT = "reboot" # matched as a PREFIX, like CommonCLI does + +# Commands the CLI reaches but the portal cannot honestly serve; rejected at +# POST. Mirrors wcCliUnavailable() in WebConfigServer.cpp. +CLI_UNAVAILABLE = [ + ("start ota", True, "start ota needs port 80, which this portal is using. " + "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 ` 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."), +] + + +def cli_unavailable(cmd): + for token, is_prefix, why in CLI_UNAVAILABLE: + if cmd.startswith(token) if is_prefix else cmd == token: + return why + return None + + +# 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 + if reply.startswith(("Err", "ERR", "err", "(ERR", "Unknown command", + "unknown config", "??", "Can't find")): + return True + return ": Err" in reply + +# Commands the device answers but that have no config-key equivalent. +GETTERS = { + "freq": lambda c: "%.3f" % c["radio"]["freq"], + "bw": lambda c: "%.2f" % c["radio"]["bw"], + "sf": lambda c: str(c["radio"]["sf"]), + "cr": lambda c: str(c["radio"]["cr"]), + "public.key": lambda c: "a1b2c3d4" * 8, + "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)), + "mqtt.status": lambda c: cli_mqtt_status(c), + "mqtt.presets": lambda c: "\n".join( + "%2d. %s%s" % (i + 1, n, "" if nd == "none" else " (needs %s)" % nd) + for i, (n, nd) in enumerate(PRESETS)), + "role": lambda c: "Repeater", + "acl": lambda c: "a1b2c3d4e5f60718 perms 3\n1122334455667788 perms 1", + # not its own pref: the CLI derives it from airtime_factor both ways + "dutycycle": lambda c: "%.1f" % (100.0 / (c["radio"]["af"] + 1)), + "mqtt.config.valid": lambda c: ( + "yes" if any(s["preset"] != "none" for s in c["mqtt"]["slots"]) else "no - no slot configured"), + "mqtt.ntp.diag": lambda c: "last sync: 42s ago via %s (offset +0.011s)" % (c["mqtt"]["ntp"] or "none"), + "mqtt.stats": lambda c: ("published: %d\ndropped: 0\nqueue: 0/24\nreconnects: 1" + % (100 + int(time.time() - ST.start))), + # Runtime-gated on the real device (Board::canControlLoRaFemLna), not + # compiled out — the command exists everywhere and the board answers for + # itself. The mock board is a Heltec V3, which has no FEM. + "radio.fem.rxgain": lambda c: None, +} + + +def cli_mqtt_status(cfg): + lines = [] + for i, s in enumerate(cfg["mqtt"]["slots"][:ST.active_slots]): + if s["preset"] == "none": + lines.append("slot %d: unconfigured" % (i + 1)) + else: + lines.append("slot %d: %-16s connected tx=%d err=0" + % (i + 1, s["preset"], 100 + int(time.time() - ST.start))) + return "\n".join(lines) + + +def cli_get(cfg, key): + """Reply to `get `. + + CommonCLI::handleGetCmd answers `> value` — the marker sets the value apart + on the serial console. Reproduced here because it is load-bearing: a reply + that starts with "> " does not start with "OK", which is what made the + firmware's first cut mark every getter a failure. + """ + ok, val = _cli_get_value(cfg, key) + return (ok, "> " + val) if ok else (ok, val) + + +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 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)" + val = cli_read_key(cfg, key) + if val is None: + return False, "??: %s" % key # CommonCLI::handleGetCmd fallthrough + return True, str(val) + + +def cli_read_key(cfg, key): + """Current value of a `set` key, or None when the key is unknown.""" + m = re.match(r"^mqtt([1-6])\.(\w+)$", key) + if m: + slot = cfg["mqtt"]["slots"][int(m.group(1)) - 1] + return slot.get(m.group(2)) + for table in (BOOL_KEYS, INT_KEYS, FLOAT_KEYS, STR_KEYS, SECRET_STR_KEYS): + if key in table: + sec, f = table[key] + v = cfg[sec][f] + return ("on" if v else "off") if key in BOOL_KEYS else v + # keys apply_set() special-cases, so they appear in none of the tables above + r = cfg["radio"] + return { + "name": r["name"], "lat": r["lat"], "lon": r["lon"], + "radio": "%.3f,%.2f,%d,%d" % (r["freq"], r["bw"], r["sf"], r["cr"]), + "bw": r["bw"], "sf": r["sf"], "cr": r["cr"], + "mqtt.iata": cfg["mqtt"]["iata"], "mqtt.owner": cfg["mqtt"]["owner"], + }.get(key) + + +def run_cli(cfg, line): + """Execute one command line. Returns (ok, reply); reply may be multi-line.""" + cmd = line.strip() + if cmd == "": + return True, "" + if cmd == "ver": + # Same source as /api/status's fw on the device: both are + # FIRMWARE_VERSION, so they must not disagree here either. + return True, "%s (Build: 6 Jun 2026)" % FW_VERSION + if cmd == "board": + return True, "Heltec V3 (mock)" + if cmd == "clock": + return True, time.strftime("%d/%m/%Y %H:%M:%S", time.gmtime()) + " UTC" + if cmd == "advert": + return True, "OK - Advert sent (zero hop)" + if cmd == "advert.zerohop": + return True, "OK - Advert sent (zero hop)" + if cmd in ("reboot", "clkreboot"): + return True, "OK - rebooting" + if cmd in ("poweroff", "shutdown"): + return True, "OK - powering off" + if cmd == "erase": + return True, "File system erase: OK" + if cmd == "memory": + return True, ("heap free: 142000\nheap min: 118000\n" + "largest block: 96000\npsram free: 3980000") + if cmd == "neighbors": + return True, ("d4e5f60718 -71 dBm snr 9.5 2m ago\n" + "1122334455 -94 dBm snr 2.0 14m ago") + # Handled by MyMesh::handleCommand before it delegates to CommonCLI. + if cmd == "discover.neighbors": + return True, "OK - Discover sent" + if cmd == "discover.scopes": + return True, "OK - scopes queued (18s discovery remaining)" + if cmd.startswith("setperm "): + parts = cmd[8:].split() + if len(parts) != 2 or not _hex64(parts[0]): + return False, "Err - bad params" + return True, "OK" + if cmd.startswith("clock sync"): + # Rejected at POST, but modelled anyway: over the web the caller's + # timestamp is 0, so CommonCLI always takes this branch. + return False, "(ERR: clock cannot go backwards)" + if cmd == "region": + return True, "US915" + if cmd == "sensor list": + return True, "0: battery (mV)\n1: temperature (C)\n2: humidity (%)" + if cmd.startswith("sensor get "): + return True, "> 22.4" + if cmd.startswith("sensor set "): + return True, "OK" + if cmd.startswith("gps advert "): + mode = cmd[11:] + if mode not in ("none", "share", "prefs"): + return False, "Error, must be none, share or prefs" + return True, "OK - advert position: %s" % mode + if cmd in ("gps on", "gps off"): + return True, "OK - GPS %s" % cmd[4:] + if cmd == "gps sync": + return True, "OK - clock and location set from GPS" + if cmd == "gps setloc": + return True, "OK - lat/lon set from the current fix" + if cmd == "gps": + return True, "GPS: no fix (0 satellites)" + if cmd in ("powersaving on", "powersaving off"): + return True, "OK - power saving %s" % cmd[12:] + if cmd == "powersaving": + return True, "off" + if cmd.startswith("alert test"): + if not ST.cfg["cli"]["alert.psk"]: + return False, "Error: alert channel not configured (set alert.psk or set alert.hashtag)" + return True, "OK - test alert sent" + if cmd.startswith("ota "): + return True, ("v1.7.2 available (current v1.7.1-mock)" if cmd == "ota check" + else "OK - downloading v1.7.2, will reboot when flashed") + if cmd.startswith("start webconfig"): + return True, "OK - already running (you are using it)" + if cmd == "stop webconfig": + return True, "OK - portal stopping" + if cmd == "start ota": + return True, "OK - upload AP raised at 192.168.4.1" + if cmd.startswith("neighbor.remove "): + return (True, "OK") if _hex64(cmd[16:]) else (False, "ERR: bad pubkey") + if cmd.startswith("tempradio "): + return True, "OK - temporary radio params applied (not saved)" + if cmd == "clear stats": + return True, "OK - stats cleared" + if cmd.startswith("stats-"): + return True, "recv=512 sent=88 rx_err=3 airtime=41s" + if cmd == "log": + return True, "packet log: 128 entries, 14 KB" + if cmd.startswith("log "): + return True, "OK" + if cmd.startswith("password "): + global ADMIN_PASSWORD + ADMIN_PASSWORD = cmd[9:] + return True, "OK - password changed" + if cmd.startswith("time "): + return True, "OK - clock set" + if cmd.startswith("get "): + return cli_get(cfg, cmd[4:].strip()) + if cmd.startswith("set "): + rest = cmd[4:].strip() + key, _, val = rest.partition(" ") + if not key: + return False, "Error: set what?" + # apply_set owns the "is this settable" decision; gating on whether the + # key is *readable* rejected write-only and computed ones (`dutycycle`, + # `prv.key`, `radio.fem.rxgain`). + return apply_set(cfg, key, val.strip()) + return False, "Unknown command" def valid_reqid(reqid): @@ -393,6 +741,10 @@ class Handler(BaseHTTPRequestHandler): if self._need_auth(): return return self._config_result() + if path == "/api/cli/result": + if self._need_auth(): + return + return self._cli_result() if path == "/api/stats": if self._need_auth(): return @@ -415,6 +767,10 @@ class Handler(BaseHTTPRequestHandler): if self._need_auth(): return return self._config_post() + if path == "/api/cli": + if self._need_auth(): + return + return self._cli_post() if path == "/api/reboot": if self._need_auth(): return @@ -431,6 +787,12 @@ class Handler(BaseHTTPRequestHandler): except OSError: self.send_error(500, "webui/index.html not found") return + if MINIFY: + # Serve what the device actually serves. The generator strips + # comments and indentation before compressing, so --minify is how + # you exercise those bytes in a browser rather than trusting that + # stripping a 100 KB page never changes its behaviour. + html = strip_source(html.decode("utf-8")).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(html))) @@ -526,6 +888,125 @@ class Handler(BaseHTTPRequestHandler): "reboot": b["reboot"] and b["all_ok"], "results": b["results"], }) + # ---- CLI --------------------------------------------------------------- + # Same 202 + reqid + poll shape as /api/config, for the same reason: the + # commands have to run on the main loop, not the web server's task. The + # difference is that results stream -- a pasted sequence fills the terminal + # command by command instead of appearing all at once at the end. + def _cli_post(self): + raw = self._read_body() + if len(raw) > 8192: + return self._json(413, {"error": "body too large"}) + try: + body = json.loads(raw or b"{}") + except ValueError: + return self._json(400, {"error": "bad json"}) + reqid = body.get("reqid", "") + if not valid_reqid(reqid): + return self._json(400, {"error": "bad reqid"}) + cmds = body.get("cmds") + if not isinstance(cmds, list) or not cmds: + return self._json(400, {"error": "no commands"}) + if len(cmds) > CLI_MAX_CMDS: + return self._json(413, {"error": "too many commands", "max": CLI_MAX_CMDS}) + cmds = [str(c).replace("\r", "").replace("\n", "").strip() for c in cmds] + cmds = [c for c in cmds if c] + if not cmds: + return self._json(400, {"error": "no commands"}) + for c in cmds: + if len(c) > BATCH_CMD_SIZE - 1: + return self._json(400, {"error": "command too long"}) + why = cli_unavailable(c) + if why: + return self._json(400, {"error": why}) + # Same invariant handleConfigPost enforces (see wcCliUnavailable's + # neighbour in WebConfigServer.cpp): first onboarding is committed by the + # reboot, and must not commit the factory password onto someone's LAN. + if (ST.setup_mode and ST.initial_setup and not ST.admin_pwd_set + and not any(c.startswith("password ") for c in cmds) + and (any(c.startswith(CLI_DEFERRED_REBOOT) for c in cmds) + or any(c.startswith("set wifi.ssid ") for c in cmds))): + return self._json(400, {"error": "admin password required for initial setup — " + "run `password ` first"}) + + with ST.lock: + self._cli_advance(ST.cli) + if ST.cli.get("state") != "idle" and ST.cli.get("reqid") == reqid: + return self._json(202, {"state": ST.cli["state"], "reqid": reqid, + "total": len(ST.cli["cmds"])}) + if ST.cli.get("state") == "running": + return self._json(409, {"error": "busy", "reqid": ST.cli.get("reqid", "")}) + ST.cli = {"state": "running", "reqid": reqid, "cmds": cmds, "results": [], + "all_ok": True, + "reboot": any(c.startswith(CLI_DEFERRED_REBOOT) for c in cmds), + "next_at": time.time() + CLI_CMD_SECS} + return self._json(202, {"state": "running", "reqid": reqid, "total": len(cmds)}) + + @staticmethod + def _cli_advance(job): + """Run whichever queued commands are now due. Execution belongs to the + node's loop, not to the client's polling — a client that walks away must + not leave the executor claimed forever.""" + now = time.time() + while (job.get("state") == "running" and len(job["results"]) < len(job["cmds"]) + and now >= job["next_at"]): + cmd = job["cmds"][len(job["results"])] + if cmd.startswith(CLI_DEFERRED_REBOOT): + reply = "OK - reboot queued" + else: + _, reply = run_cli(ST.cfg, cmd) + 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). + if cmd.startswith(("set ", "password ")): + job["all_ok"] = job.get("all_ok", True) and reply.startswith("OK") + # The command is NOT echoed: it may carry a password or token, and + # the client matches results to its own sequence by index. + job["results"].append({"ok": ok, "reply": reply}) + job["next_at"] = now + CLI_CMD_SECS + if job.get("state") == "running" and len(job["results"]) == len(job["cmds"]): + job["state"] = "done" # stays readable until the next POST + + def _cli_result(self): + query = parse_qs(urlsplit(self.path).query) + reqid = query.get("reqid", [""])[0] + if not valid_reqid(reqid): + return self._json(400, {"error": "bad reqid"}) + # `from` lets the client ask only for results it has not rendered yet, + # so a long sequence isn't re-sent on every poll. + try: + frm = max(0, int(query.get("from", ["0"])[0])) + except ValueError: + frm = 0 + with ST.lock: + j = ST.cli + if j.get("state") == "idle": + return self._json(200, {"state": "idle", "reqid": reqid}) + if j.get("reqid") != reqid: + return self._json(404, {"error": "unknown request"}) + self._cli_advance(j) # one command per CLI_CMD_SECS + # Results stream, capped per read so the device's JSON document + # stays small; a longer sequence pages across reads. "done" means + # the client has been handed everything, not just that execution + # finished — a client that stops polling at "done" must lose nothing. + page = j["results"][frm:frm + CLI_RESULT_PAGE] + final = j["state"] == "done" and frm + len(page) >= len(j["cmds"]) + body = {"state": "done" if final else "running", "reqid": reqid, + "total": len(j["cmds"]), "from": frm, "results": page} + if final: + body["all_ok"] = j["all_ok"] + body["reboot"] = j["reboot"] and j["all_ok"] + if j["reboot"] and not j["all_ok"]: + body["reboot_withheld"] = True + return self._json(200, body) + def _scan(self): rescan = "rescan=1" in self.path now = time.time() @@ -561,17 +1042,22 @@ class Handler(BaseHTTPRequestHandler): def main(): - global ST, PORT + global ST, PORT, MINIFY, FW_VERSION ap = argparse.ArgumentParser(description="Mock WebConfig portal backend") ap.add_argument("--port", type=int, default=8080) ap.add_argument("--setup", action="store_true", help="first-boot setup wizard mode") ap.add_argument("--active-slots", type=int, default=5, help="server slots to expose (2 or 5)") + ap.add_argument("--fw-version", default=FW_VERSION, + help="version string to report, shaped like build.sh's embedded one") + ap.add_argument("--minify", action="store_true", + help="serve the comment-stripped page the device ships, not the source") args = ap.parse_args() - ST, PORT = State(args), args.port + ST, PORT, MINIFY = State(args), args.port, args.minify + FW_VERSION = args.fw_version srv = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) mode = "SETUP (wizard)" if args.setup else "LAN (login: %s)" % ADMIN_PASSWORD - print("WebConfig mock backend — %s" % mode) + print("WebConfig mock backend — %s%s" % (mode, " [minified]" if MINIFY else "")) print(" open http://localhost:%d/ (Ctrl-C to stop)" % args.port) try: srv.serve_forever() diff --git a/src/helpers/WebConfigBatch.h b/src/helpers/WebConfigBatch.h index 2f93f56d..5532bbad 100644 --- a/src/helpers/WebConfigBatch.h +++ b/src/helpers/WebConfigBatch.h @@ -1,5 +1,6 @@ #pragma once +#include // size_t / NULL for the reply classifiers below #include // Fork-owned, dependency-free spec for the WebConfig "config batch / reboot / @@ -168,6 +169,110 @@ static inline uint32_t confirmRebootAt(uint32_t now) { return scheduleAt(now, kRebootConfirmMs); } +// -------------------------------------------------------------------------- +// CLI sequences (/api/cli). The terminal shares this one deferred-command slot +// with config saves rather than owning a second MAX_BATCH array: both drain on +// the loop task, both are single-slot, and a duplicate would cost ~8 KB of +// permanently resident RAM. Sharing also makes a save and a CLI run mutually +// exclusive, which they must be. +// +// Two things differ from a config save: +// 1. results stream. A save's results appear only when the whole batch is +// Done; a CLI read hands back whatever has executed so far, so a pasted +// sequence fills the terminal command by command. +// 2. the reboot is not requested by a `reboot` flag on the request but by the +// word `reboot` appearing in the sequence. It is deferred rather than +// executed, because Board::reboot() does not return and would take the node +// down before the client could read a single result. +// -------------------------------------------------------------------------- + +// Results returned by one read. Bounds the JSON document built on the +// async_tcp task; a longer sequence pages across successive reads. +static const int kCliResultPage = 8; + +static inline int cliPageCount(int from, int produced, int page) { + const int pending = produced - from; + if (pending <= 0) return 0; + return pending > page ? page : pending; +} + +// "done" means the client has been handed every result, not merely that +// execution finished: the last page may still be unread, and a client that +// stops polling at "done" would lose it. +static inline bool cliReadIsFinal(State state, int from, int page_count, int total) { + return state == State::Done && from + page_count >= total; +} + +// A trailing `reboot` is withheld when any command in the sequence failed, +// exactly as a config save's is. The operator asked for the reboot, but +// rebooting into a half-applied config — over a link they may not get back — +// is the worse failure, and the result body reports the refusal. +static inline bool cliRebootAllowed(bool has_reboot, bool all_ok) { + return has_reboot && all_ok; +} + +// CommonCLI has no single failure convention. Testing only for an "Err" prefix +// let five other shapes through as success — including "Unknown command" and +// "unknown config: x", the two an operator hits most — which coloured them +// green AND let a queued reboot proceed after them. +// +// Every shape below is a literal from CommonCLI.cpp / CommonCLI_Observer.cpp. +// This list is the fragile part of the design: a new failure string added there +// is silently a success here. Which is exactly why it must not be what decides +// whether to reboot — see cliReplyConfirmsWrite. +static inline bool cliReplyIsFailure(const char* r) { + if (r == NULL || r[0] == 0) return false; // empty is normalised to "OK" + static const char* const kPrefixes[] = { + "Err", "ERR", "err", // "Err - ", "ERR: ", "Error: " + "(ERR", // "(ERR: clock cannot go backwards)" + "Unknown command", + "unknown config", + "??", // "??: " from the get fallthrough + "Can't find", // "Can't find GPS" + }; + for (size_t i = 0; i < sizeof(kPrefixes) / sizeof(kPrefixes[0]); i++) { + const char* p = kPrefixes[i]; + size_t n = 0; + while (p[n]) n++; + bool match = true; + for (size_t j = 0; j < n; j++) { + if (r[j] != p[j]) { match = false; break; } + } + if (match) return true; + } + // "File system erase: Err" reports the failure at the END of the reply. + for (size_t i = 0; r[i]; i++) { + if (r[i] == ':' && r[i + 1] == ' ' && r[i + 2] == 'E' && r[i + 3] == 'r' && + r[i + 4] == 'r') return true; + } + return false; +} + +// Whether a command's reply is allowed to gate the deferred reboot. +// +// Only writes are, and only writes have a reply convention worth trusting: +// every setter answers with an "OK" prefix. Diagnostics do not — `memory` +// answers "Free: ...", a getter answers "> value" — so letting them gate would +// mean guessing, and guessing wrong here either strands the operator (a +// harmless `memory` blocks their reboot) or reboots into a config that did not +// apply. The question the gate exists to answer is narrower than "did anything +// fail": it is "did every setting I asked for actually take". +static inline bool cliReplyGatesReboot(const char* cmd) { + if (cmd == NULL) return false; + const char* set = "set "; + const char* pwd = "password "; + bool is_set = true, is_pwd = true; + for (int i = 0; i < 4; i++) if (cmd[i] != set[i]) { is_set = false; break; } + for (int i = 0; i < 9; i++) if (cmd[i] != pwd[i]) { is_pwd = false; break; } + return is_set || is_pwd; +} + +// A write took effect iff its reply starts with "OK" — the one convention every +// setter in CommonCLI actually keeps. +static inline bool cliWriteSucceeded(const char* reply) { + return reply != NULL && reply[0] == 'O' && reply[1] == 'K'; +} + // -------------------------------------------------------------------------- // Reboot fire (.cpp:262-265) and isRebootPending (.cpp:70-74). // -------------------------------------------------------------------------- diff --git a/src/helpers/WebConfigKeys.h b/src/helpers/WebConfigKeys.h index 629df352..47fd4149 100644 --- a/src/helpers/WebConfigKeys.h +++ b/src/helpers/WebConfigKeys.h @@ -82,6 +82,28 @@ static inline bool wcIsSecretKey(const char* key) { return false; } +// CommonCLI answers a secret getter in plaintext only for the serial console +// (sender_timestamp 0) and masks it for remote callers. The web CLI executes +// with sender_timestamp 0 — that is what makes `erase`, `stats-*` and `set freq` +// reachable — so it would otherwise inherit the serial console's plaintext +// answers for an HTTP request. This says which `get` commands must be masked +// back down, restoring the distinction for a caller not at the serial port. +// +// Writing these has always been possible from the portal; reading them never +// was, because handleConfigGet masks them (wcIsSecretKey). The two are different +// capabilities: replacing a WiFi password does not reveal the current one, and +// replacing an identity does not reveal the existing private key. +static inline bool wcIsSecretReadCommand(const char* cmd) { + if (strncmp(cmd, "get ", 4) != 0) return false; + const char* key = cmd + 4; + while (*key == ' ') key++; + if (strcmp(key, "prv.key") == 0) return true; // this node's identity + if (strcmp(key, "guest.password") == 0) return true; + if (strcmp(key, "alert.psk") == 0) return true; + if (strcmp(key, "bridge.secret") == 0) return true; + return wcIsSecretKey(key); // wifi.pwd, mqttN.password, mqttN.token +} + // Browser-generated request IDs are exactly eight random bytes encoded as // hexadecimal. Keeping the grammar deliberately small makes the ID safe to // echo in JSON/logs and prevents an empty or truncated ID from weakening the diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index b9108001..34bdca4b 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -28,6 +28,89 @@ static const char SECRET_SENTINEL[] = "********"; static inline bool isAllowedSetKey(const char* key) { return wcIsAllowedSetKey(key); } static inline bool isSecretKey(const char* key) { return wcIsSecretKey(key); } +// Commands whose CLI handler never returns would take the node down mid-drain, +// before the client could read a single result. `reboot` is deferred instead: +// it is not passed to the CLI at all, and the batch arms the ordinary reboot +// path once the operator has read the results. The rest (clkreboot, poweroff, +// ota update) do real work on the way down and cannot be faked, so they run +// normally and the connection drops — the UI warns before sending them. +// CommonCLI dispatches on a 6-byte PREFIX (memcmp(command, "reboot", 6)), so +// `reboot now` and `rebooted` reach Board::reboot() too. Matching exactly here +// let those through to the CLI, which took the node down mid-drain with no +// results and no deferral — the precise failure the deferral exists to avoid. +// Whatever CommonCLI would treat as a reboot, this must intercept. +static inline bool wcIsDeferredReboot(const char* cmd) { + return strncmp(cmd, "reboot", 6) == 0; +} + +// Commands the CLI reaches but the portal cannot honestly serve. Rejected at +// POST so nothing in the sequence runs, rather than failing halfway with a +// reply that does not explain itself. Returns the reason, or NULL if fine. +static const char* wcCliUnavailable(const char* cmd) { + // ESP32Board::startOTAUpdate() does `new AsyncWebServer(80)` with no bind + // check and answers "Started" regardless. The portal already holds port 80, + // so from here it can only leak the allocation, inhibit sleep, and lie. + if (strncmp(cmd, "start ota", 9) == 0) { + return "start ota needs port 80, which this portal is using. " + "Run it from the serial console, or use `ota update`."; + } + // `clock sync` sets the clock from the CALLER's timestamp. Web requests carry + // none (execCommand passes 0), so CommonCLI always rejects it as moving the + // clock backwards. `time ` is the one that works over this transport. + if (strncmp(cmd, "clock sync", 10) == 0) { + return "clock sync takes its time from the caller, which a web request has " + "no way to supply. Use `time ` instead."; + } + // Both write their real output to Serial and hand back a stub the terminal + // would render as success. Bare `log` also streams a whole file from the loop + // task, stalling the mesh and the radio while it does. + if (strcmp(cmd, "log") == 0) { + return "log writes the packet log to the serial console, not here, and " + "blocks the radio while it does. Use `log start` / `log stop`."; + } + if (strcmp(cmd, "get acl") == 0) { + return "get acl writes to the serial console, not here."; + } + return NULL; +} +// The `password` command echoes the new password back in its reply, and replies +// 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; +} + +// CommonCLI splits its surface by CALLER, not by command: a serial caller +// (sender_timestamp 0, physical access) reads secrets in plaintext, while a +// remote one gets "******** (serial only)". Its own comments say so — "Serial +// only (WiFi creds grant LAN access); remote sees set/unset". +// +// execCommand passes 0, which is what makes `erase`, `stats-*` and `set freq` +// reachable from the terminal at all. Left alone, that also claims +// physical-access trust for an HTTP request: `get prv.key` would hand this +// node's identity to anyone associated with the open setup AP, and `get +// wifi.pwd` would hand over the operator's network. Reading a secret and +// writing one are not the same capability — the wizard has always been able to +// REPLACE these; nothing in the portal could ever READ them, because +// /api/config masks them (wcIsSecretKey). +// +// So the command surface stays whole and only the READ is masked, restoring the +// distinction CommonCLI intended for a caller who is not at the serial port. +// Which commands those are lives in WebConfigKeys.h (wcIsSecretReadCommand), +// beside the rest of the secret classification and host-tested with it. + +// Keep the set/unset signal, which is the useful part and what CommonCLI itself +// reports remotely; only the value goes. A getter answers "> value". +static void wcMaskSecretReply(char* reply) { + const char* val = reply; + if (val[0] == '>' && val[1] == ' ') val += 2; + const bool unset = (val[0] == 0 || strcmp(val, "(not set)") == 0); + strcpy(reply, unset ? "> (not set)" : "> ******** (serial only)"); +} +// Reply classification lives in WebConfigBatch.h with the rest of the decisions +// (WebConfigBatch::cliReplyIsFailure / cliReplyGatesReboot / cliWriteSucceeded), +// so the shapes CommonCLI actually emits are enumerated in one host-tested place. + // Constant-time-ish comparison so login timing doesn't leak a prefix match. static bool fixedTimeEquals(const char* a, const char* b, size_t max_len) { size_t la = strnlen(a, max_len), lb = strnlen(b, max_len); @@ -50,6 +133,17 @@ struct WCLock { WebConfigServer* WebConfigServer::_active = NULL; AsyncWebServer* WebConfigServer::_host = NULL; +// Out-of-line definitions for the in-class-initialised constants. An in-class +// initialiser is only a declaration under C++11 (what the xtensa-esp32 +// toolchain builds with), so any use that binds a reference rather than reading +// the value — ArduinoJson takes its argument as `const T&` — needs the symbol to +// exist. Comparisons like `count >= MAX_BATCH` never did, which is why this only +// surfaced when MAX_BATCH started being reported in JSON, and then only on the +// targets where the compiler happened not to fold it. +const int WebConfigServer::MAX_BATCH; +const size_t WebConfigServer::MAX_BODY; +const uint32_t WebConfigServer::STOP_WARN_MS; + // Protects the permanent route host's active-session pointer and handler // references across the loop and async_tcp cores. The critical sections only // copy a pointer/update a counter; handlers themselves never run under it. @@ -57,9 +151,10 @@ static portMUX_TYPE s_wc_route_mux = portMUX_INITIALIZER_UNLOCKED; WebConfigServer::WebConfigServer(NodePrefs* prefs, MQTTPrefs* obs, Callbacks* callbacks, const uint8_t* pub_key, const char* fw_ver, + const char* build_date, const char* role, const char* board_name) : _prefs(prefs), _obs(obs), _cb(callbacks), _pub_key(pub_key), - _fw_ver(fw_ver), _role(role), _board_name(board_name) { + _fw_ver(fw_ver), _build_date(build_date), _role(role), _board_name(board_name) { _mux = xSemaphoreCreateMutex(); } @@ -315,20 +410,51 @@ void WebConfigServer::drainBatch(uint32_t now) { // WiFi regardless, so a concurrent GET waiting on it costs nothing extra. { WCLock lock(_mux); - _cb->execCommand(e.cmd, e.reply); + // A CLI `reboot` is answered here rather than executed: Board::reboot() + // does not return, so running it would take the node down before the + // operator could read whether the preceding commands succeeded. The + // batch arms the ordinary deferred reboot on the first result read. + if (_batch_kind == BATCH_CLI && wcIsDeferredReboot(e.cmd)) { + // Deliberately non-committal: whether the reboot actually happens is + // not known until the whole sequence has run (it is withheld if any + // command failed), and a later command could still fail after this one. + strcpy(e.reply, "OK - reboot queued"); + } else { + _cb->execCommand(e.cmd, e.reply); + } if (e.reply[0] == 0) strcpy(e.reply, "OK"); // The upstream `password` command echoes the new password back in its // reply, and replies are served to the client over the open setup AP. // Overwrite it: the command cannot fail, so there is nothing to report. - if (wcIsAdminPasswordKey(e.key)) strcpy(e.reply, "OK"); - // Success convention across every allowlisted setter is an "OK" prefix - // (the UI relies on the same test); anything else is a rejection. - _batch_all_ok = WebConfigBatch::nextAllOk(_batch_all_ok, - strncmp(e.reply, "OK", 2) == 0); + // Config entries carry the key; a CLI entry is matched on the command. + const bool set_admin_pwd = wcIsAdminPasswordKey(e.key) || wcCliEchoesSecret(e.cmd); + if (set_admin_pwd) strcpy(e.reply, "OK"); + // Satisfies the initial-setup invariant for the rest of this session, so + // the operator can set the password and configure WiFi in separate steps + // (the form batch always sends them together and needs no such memory). + if (set_admin_pwd) _admin_pwd_set = true; + if (_batch_kind == BATCH_CLI && wcIsSecretReadCommand(e.cmd)) wcMaskSecretReply(e.reply); + // What gates the reboot is narrower than "did anything fail": only a + // write can leave the node in a config not worth rebooting into, and only + // a write has a reply convention ("OK") solid enough to test. Diagnostics + // in the sequence neither gate it nor get guessed at. + if (_batch_kind != BATCH_CLI || WebConfigBatch::cliReplyGatesReboot(e.cmd)) { + _batch_all_ok = WebConfigBatch::nextAllOk( + _batch_all_ok, WebConfigBatch::cliWriteSucceeded(e.reply)); + } } _batch_last_cmd = millis(); - Serial.printf("WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next, (int)_batch_count, - e.key, (unsigned long)(_batch_last_cmd - t0)); + // Config entries are named by their (non-secret) key. A CLI command is + // deliberately not logged: the operator can see what they typed, and a + // `set wifi.pwd` or `password` from the terminal must not reach the serial + // log, which is a different audience from the browser session. + if (_batch_kind == BATCH_CLI) { + Serial.printf("WC: cli %d/%d took %lums\n", (int)_batch_next, (int)_batch_count, + (unsigned long)(_batch_last_cmd - t0)); + } else { + Serial.printf("WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next, (int)_batch_count, + e.key, (unsigned long)(_batch_last_cmd - t0)); + } if (!WebConfigBatch::drainFinished(_batch_next, _batch_count)) { return; // more commands next tick } @@ -418,6 +544,10 @@ void WebConfigServer::registerRoutes() { _server->on("/api/config", HTTP_GET, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleConfigGet); }); _server->on("/api/config", HTTP_POST, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleConfigPost); }, NULL, collectBody); + // Same specific-route-first rule as /api/config above. + _server->on("/api/cli/result", HTTP_GET, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleCliResult); }); + _server->on("/api/cli", HTTP_POST, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleCliPost); }, + NULL, collectBody); _server->on("/api/stats", HTTP_GET, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleStats); }); _server->on("/api/scan", HTTP_GET, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleScan); }); _server->on("/api/reboot", HTTP_POST, [](AsyncWebServerRequest* r) { dispatchRequest(r, &WebConfigServer::handleReboot); }); @@ -487,6 +617,9 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) { for (int i = 0; i < 8; i++) sprintf(&node_id[i * 2], "%02x", _pub_key[i]); doc["node_id"] = node_id; doc["fw"] = _fw_ver; + // The page shows a trimmed version — base + build number + channel — and + // pairs it with this, the way `ver` does. Both come from the same defines. + doc["build_date"] = _build_date; doc["role"] = _role; doc["board"] = _board_name; doc["uptime_s"] = millis() / 1000; @@ -495,6 +628,10 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) { // Servers the UI should expose: only as many as can actually be active at // once (2 without PSRAM, 5 with). Configuring more never connects. doc["active_slots"] = MQTTBridge::getMaxActiveSlots(); + // Commands the terminal may submit at once. The CLI shares the config + // batch's fixed slot, so the cap is MAX_BATCH — reported rather than + // duplicated in the page, which cannot know how this build was sized. + doc["max_cmds"] = MAX_BATCH; AsyncResponseStream* res = req->beginResponseStream("application/json"); serializeJson(doc, *res); @@ -781,6 +918,7 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) { req->send(400, "application/json", "{\"error\":\"no changes\"}"); return; } + _batch_kind = BATCH_CONFIG; _batch_count = count; _batch_next = 0; _batch_reboot = reboot_after; @@ -820,8 +958,13 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) { // fires but no branch print follows, the handler is blocked on _mux. Serial.printf("WC: result entry mode=%d state=%d\n", (int)_mode, (int)_batch_state); WCLock lock(_mux); - const WebConfigBatch::ResultOutcome outcome = WebConfigBatch::classifyResult( - toSpecState(_batch_state), strcmp(requested_reqid.c_str(), _batch_reqid) == 0); + // A CLI sequence occupying the shared slot is not a config save, whatever the + // reqid says: its entries have no `key` and its results belong to the + // terminal's reader. Treat it as unknown here (and vice versa there). + const bool mine = (_batch_kind == BATCH_CONFIG) && + (strcmp(requested_reqid.c_str(), _batch_reqid) == 0); + const WebConfigBatch::ResultOutcome outcome = + WebConfigBatch::classifyResult(toSpecState(_batch_state), mine); if (outcome == WebConfigBatch::ResultOutcome::Idle) { Serial.println("WC: result read -> idle"); StaticJsonDocument<64> idle; @@ -877,6 +1020,238 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) { req->send(res); } +// --------------------------------------------------------------------------- +// CLI terminal (/api/cli). Same 202 + reqid + poll contract as a config save, +// and for the same reason: CommonCLI touches prefs, the radio and the +// filesystem, none of which may be reached from the async_tcp task. The +// commands go into the shared deferred slot and tick() drains them. +// +// Unlike a save this is NOT allowlisted. That is the point: the terminal exists +// to reach what the serial console reaches, and execCommand() passes +// sender_timestamp 0, so it gets the same local privilege the serial console +// has. Authentication is the boundary — as it is for serial (physical access) +// and for remote admin over the mesh (the admin password). +// --------------------------------------------------------------------------- + +// Commands whose CLI handler never returns would take the node down mid-drain, +// before the client could read a single result. `reboot` is deferred instead: +// it is not passed to the CLI at all, and the batch arms the ordinary reboot +// path once the operator has read the results. The rest (clkreboot, poweroff, +// ota update) do real work on the way down and cannot be faked, so they run +// normally and the connection drops — the UI warns before sending them. +void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + if (req->_tempObject == NULL) { + req->send(413, "application/json", "{\"error\":\"body too large\"}"); + return; + } + const char* body = (const char*)req->_tempObject; + DynamicJsonDocument doc(6144); + if (!body || deserializeJson(doc, body) != DeserializationError::Ok) { + req->send(400, "application/json", "{\"error\":\"bad json\"}"); + return; + } + const char* reqid = doc["reqid"] | ""; + if (!wcIsValidReqId(reqid)) { + req->send(400, "application/json", "{\"error\":\"bad reqid\"}"); + return; + } + JsonArray cmds = doc["cmds"]; + if (cmds.isNull()) { + req->send(400, "application/json", "{\"error\":\"no commands\"}"); + return; + } + + WCLock lock(_mux); + // Replay/Busy exactly as a config save classifies them: a repeated POST is + // acknowledged rather than executed twice, and a different sequence while one + // is still draining is refused. + const WebConfigBatch::State bstate = toSpecState(_batch_state); + const bool reqid_matches = (strcmp(reqid, _batch_reqid) == 0); + const WebConfigBatch::PostOutcome pre = + WebConfigBatch::classifyPost(bstate, reqid_matches, 1 /* count unknown yet */, false); + if (pre == WebConfigBatch::PostOutcome::Replay) { + StaticJsonDocument<96> ack; + ack["state"] = (bstate == WebConfigBatch::State::Done) ? "done" : "running"; + ack["total"] = _batch_count; + ack["reqid"] = (const char*)_batch_reqid; + String out; + serializeJson(ack, out); + req->send(202, "application/json", out); + return; + } + if (pre == WebConfigBatch::PostOutcome::Busy) { + StaticJsonDocument<96> bd; + bd["error"] = "busy"; + bd["reqid"] = (const char*)_batch_reqid; + String out; + serializeJson(bd, out); + req->send(409, "application/json", out); + return; + } + + int count = 0; + bool defer_reboot = false, seq_sets_pwd = false, seq_sets_ssid = false; + for (JsonVariant v : cmds) { + const char* raw = v.as(); + if (!raw) continue; + if (count >= MAX_BATCH) { + StaticJsonDocument<96> ed; + ed["error"] = "too many commands"; + ed["max"] = MAX_BATCH; + String out; + serializeJson(ed, out); + 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. + BatchEntry& e = _batch[count]; + int pos = 0; + for (const char* p = raw; *p; p++) { + if (*p == '\r' || *p == '\n') 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\"}"); + return; + } + e.cmd[pos++] = *p; + } + 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. + const char* why = wcCliUnavailable(e.cmd); + if (why) { + StaticJsonDocument<256> ed; + ed["error"] = why; + String out; + serializeJson(ed, out); + req->send(400, "application/json", out); + return; + } + 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 (strncmp(e.cmd, "set wifi.ssid ", 14) == 0) seq_sets_ssid = true; + count++; + } + if (count == 0) { + req->send(400, "application/json", "{\"error\":\"no commands\"}"); + return; + } + + // The same invariant handleConfigPost enforces, and for the same reason: the + // reboot is what commits first onboarding, and a node that reboots onto the + // LAN still holding the factory password is a known credential on someone + // else's network. The terminal warned about this client-side, which is a + // reminder, not a rule — a pasted script or a direct POST ignored it. + if (_mode == MODE_SETUP && _initial_setup && !seq_sets_pwd && !_admin_pwd_set && + (defer_reboot || seq_sets_ssid)) { + req->send(400, "application/json", + "{\"error\":\"admin password required for initial setup — " + "run `password ` first\"}"); + return; + } + + _batch_kind = BATCH_CLI; + _batch_count = count; + _batch_next = 0; + _batch_reboot = defer_reboot; + _batch_reboot_armed = false; + _batch_all_ok = true; + strncpy(_batch_reqid, reqid, sizeof(_batch_reqid) - 1); + _batch_reqid[sizeof(_batch_reqid) - 1] = 0; + _batch_state = BATCH_PENDING; // tick() picks it up on the loop task + Serial.printf("WC: cli POST accepted, %d cmds, reboot=%d\n", count, (int)defer_reboot); + + StaticJsonDocument<96> ack; + ack["state"] = "running"; + ack["total"] = count; + ack["reqid"] = (const char*)_batch_reqid; + String out; + serializeJson(ack, out); + req->send(202, "application/json", out); +} + +void WebConfigServer::handleCliResult(AsyncWebServerRequest* req) { + if (_mode == MODE_OFF) { req->send(503); return; } + if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + if (!req->hasParam("reqid")) { + req->send(400, "application/json", "{\"error\":\"bad reqid\"}"); + return; + } + String requested_reqid = req->getParam("reqid")->value(); + if (!wcIsValidReqId(requested_reqid.c_str())) { + req->send(400, "application/json", "{\"error\":\"bad reqid\"}"); + return; + } + int from = 0; + if (req->hasParam("from")) { + from = req->getParam("from")->value().toInt(); + if (from < 0) from = 0; + } + + WCLock lock(_mux); + // A config save occupying the slot is not this client's sequence, whatever + // the reqid says; treat it as unknown rather than serving `set` results + // through the terminal's reader. + const bool mine = (_batch_kind == BATCH_CLI) && + (strcmp(requested_reqid.c_str(), _batch_reqid) == 0); + const WebConfigBatch::ResultOutcome outcome = + WebConfigBatch::classifyResult(toSpecState(_batch_state), mine); + if (outcome == WebConfigBatch::ResultOutcome::Idle) { + StaticJsonDocument<64> idle; + idle["state"] = "idle"; + idle["reqid"] = requested_reqid; + String out; + serializeJson(idle, out); + req->send(200, "application/json", out); + return; + } + if (outcome == WebConfigBatch::ResultOutcome::Unknown) { + req->send(404, "application/json", "{\"error\":\"unknown request\"}"); + return; + } + + // Results stream: hand back whatever has drained since the client's cursor, + // capped so the document stays small on the async_tcp task. + const int produced = _batch_next; + const int page = WebConfigBatch::cliPageCount(from, produced, WebConfigBatch::kCliResultPage); + const bool final_read = WebConfigBatch::cliReadIsFinal(toSpecState(_batch_state), + from, page, _batch_count); + DynamicJsonDocument doc(4096); + doc["state"] = final_read ? "done" : "running"; + doc["reqid"] = (const char*)_batch_reqid; + doc["total"] = _batch_count; + doc["from"] = from; + JsonArray results = doc.createNestedArray("results"); + for (int i = from; i < from + page; i++) { + JsonObject r = results.createNestedObject(); + // The command is deliberately NOT echoed: it may hold a password or token, + // and the client already has the sequence it sent. It matches by index. + r["ok"] = !WebConfigBatch::cliReplyIsFailure(_batch[i].reply); + r["reply"] = (const char*)_batch[i].reply; + } + if (final_read) { + doc["all_ok"] = _batch_all_ok; + const bool rebooting = WebConfigBatch::cliRebootAllowed(_batch_reboot, _batch_all_ok); + doc["reboot"] = rebooting; + // Tell the operator why a `reboot` they asked for is not happening. + if (_batch_reboot && !_batch_all_ok) doc["reboot_withheld"] = true; + if (WebConfigBatch::shouldArmConfirmReboot(toSpecState(_batch_state), _batch_reboot, + _batch_all_ok, _batch_reboot_armed)) { + _batch_reboot_armed = true; + _reboot_at = WebConfigBatch::confirmRebootAt(millis()); + } + } + AsyncResponseStream* res = req->beginResponseStream("application/json"); + serializeJson(doc, *res); + req->send(res); +} + void WebConfigServer::handleStats(AsyncWebServerRequest* req) { if (_mode == MODE_OFF) { req->send(503); return; } if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h index e2d9e08a..0dcebebd 100644 --- a/src/helpers/esp32/WebConfigServer.h +++ b/src/helpers/esp32/WebConfigServer.h @@ -64,7 +64,7 @@ public: }; WebConfigServer(NodePrefs* prefs, MQTTPrefs* obs, Callbacks* callbacks, - const uint8_t* pub_key, const char* fw_ver, + const uint8_t* pub_key, const char* fw_ver, const char* build_date, const char* role, const char* board_name); ~WebConfigServer(); @@ -98,6 +98,11 @@ private: // than freeing memory still referenced by the async task. static const uint32_t STOP_WARN_MS = WebConfigBatch::kStopWarnMs; enum BatchState : uint8_t { BATCH_IDLE = 0, BATCH_PENDING, BATCH_DONE }; + // What filled the shared slot. A config save comes from allowlisted form + // fields; a CLI sequence is arbitrary commands typed into the terminal. They + // share the slot (see WebConfigBatch.h) but differ in how results are read + // and in whether `key` means anything, so every reader checks the kind. + enum BatchKind : uint8_t { BATCH_CONFIG = 0, BATCH_CLI }; // BatchState and WebConfigBatch::State are deliberately kept as separate // types (the enum is stored in a volatile member and used in prints); this @@ -110,9 +115,9 @@ private: } } struct BatchEntry { - char key[24]; // allowlisted config key (echoed back to the UI) + char key[24]; // allowlisted config key (echoed back to the UI); empty for CLI entries char cmd[160]; // full CLI command (may contain secrets - never echoed) - char reply[160]; + char reply[160]; // CLI reply budget, same 160 bytes the serial console gets }; NodePrefs* _prefs; @@ -120,6 +125,7 @@ private: Callbacks* _cb; const uint8_t* _pub_key; const char* _fw_ver; + const char* _build_date; const char* _role; const char* _board_name; @@ -130,6 +136,10 @@ private: bool _stopping = false; bool _was_setup_ap = false; bool _initial_setup = false; + // A `password` command has succeeded this session. Lets the CLI satisfy the + // initial-setup invariant across separate submissions; the form batch always + // sends the password with the rest, so it never needed the memory. + bool _admin_pwd_set = false; char _ap_ssid[33] = {0}; // Currently attached session, also used by the display's setup-info poll. @@ -141,6 +151,7 @@ private: // Command batch: filled by async_tcp under _mux, drained by tick(). volatile BatchState _batch_state = BATCH_IDLE; + volatile BatchKind _batch_kind = BATCH_CONFIG; uint8_t _batch_count = 0; uint8_t _batch_next = 0; // drain progress (one command per tick) uint32_t _batch_last_cmd = 0; @@ -199,6 +210,8 @@ private: void handleConfigGet(AsyncWebServerRequest* req); void handleConfigPost(AsyncWebServerRequest* req); void handleConfigResult(AsyncWebServerRequest* req); + void handleCliPost(AsyncWebServerRequest* req); + void handleCliResult(AsyncWebServerRequest* req); void handleStats(AsyncWebServerRequest* req); void handleScan(AsyncWebServerRequest* req); void handlePresets(AsyncWebServerRequest* req); diff --git a/test/test_webconfig_batch/test_webconfig_batch.cpp b/test/test_webconfig_batch/test_webconfig_batch.cpp index 9faddc4f..813412c5 100644 --- a/test/test_webconfig_batch/test_webconfig_batch.cpp +++ b/test/test_webconfig_batch/test_webconfig_batch.cpp @@ -190,6 +190,108 @@ TEST(WebConfigBatch, StopWarnsOnceAfterTheDeadlineThenKeepsWaiting) { EXPECT_EQ(Batch::StopAction::Wait, Batch::stopStep(2, false, 0, 999999)); } +// -------------------------------------------------------------------------- +// CLI sequences (/api/cli), which share the deferred-command slot +// -------------------------------------------------------------------------- +TEST(WebConfigBatch, CliReadPagesResultsAndNeverOverrunsWhatHasDrained) { + const int page = Batch::kCliResultPage; + // Nothing drained past the cursor yet. + EXPECT_EQ(0, Batch::cliPageCount(/*from=*/0, /*produced=*/0, page)); + EXPECT_EQ(0, Batch::cliPageCount(/*from=*/3, /*produced=*/3, page)); + // Partial progress: hand back exactly what exists. + EXPECT_EQ(3, Batch::cliPageCount(0, 3, page)); + EXPECT_EQ(2, Batch::cliPageCount(5, 7, page)); + // More available than fits in one read: cap at the page size. + EXPECT_EQ(page, Batch::cliPageCount(0, page + 5, page)); + // A cursor beyond what has drained (stale or crafted) yields nothing rather + // than a negative count that would index backwards through the batch. + EXPECT_EQ(0, Batch::cliPageCount(/*from=*/9, /*produced=*/4, page)); +} + +TEST(WebConfigBatch, CliReadIsDoneOnlyOnceEveryResultHasBeenHandedOver) { + // Still executing: never final, however much has been read. + EXPECT_FALSE(Batch::cliReadIsFinal(State::Pending, /*from=*/0, /*page=*/8, /*total=*/8)); + // Execution finished but the client has only seen the first page. Reporting + // "done" here would make a client that stops polling lose the rest. + EXPECT_FALSE(Batch::cliReadIsFinal(State::Done, /*from=*/0, /*page=*/8, /*total=*/20)); + EXPECT_FALSE(Batch::cliReadIsFinal(State::Done, /*from=*/8, /*page=*/8, /*total=*/20)); + // The read that hands over the last result is the final one. + EXPECT_TRUE(Batch::cliReadIsFinal(State::Done, /*from=*/16, /*page=*/4, /*total=*/20)); + // Re-reading past the end stays final (polls after the last page). + EXPECT_TRUE(Batch::cliReadIsFinal(State::Done, /*from=*/20, /*page=*/0, /*total=*/20)); +} + +// Every string below is a literal lifted from CommonCLI.cpp / +// CommonCLI_Observer.cpp. Testing only for an "Err" prefix passed five of these +// off as success, which both coloured them green and let a queued reboot go +// ahead after them. +TEST(WebConfigBatch, CliFailureRepliesAreRecognisedInEveryShapeCommonCLIEmits) { + const char* failures[] = { + "Err - bad params", // MyMesh setperm + "ERR: bad pubkey", // neighbor.remove + "Error: IATA code must be exactly 3 letters",// observer setters + "(ERR: clock cannot go backwards)", // clock sync, parenthesised + "Unknown command", // top-level fallthrough + "unknown config: mqtt.nope", // set fallthrough + "??: mqtt.nope", // get fallthrough + "Can't find GPS", // gps + "File system erase: Err", // failure reported at the end + }; + for (const char* f : failures) { + EXPECT_TRUE(Batch::cliReplyIsFailure(f)) << f; + } + + const char* successes[] = { + "OK", + "OK - slot 1 preset: meshrank", + "> 22", // getter value + "> msgs: on, 1: analyzer-us (ok)", // getter, contains "ok" + "File system erase: OK", // same shape, succeeded + "Free: 142832, Min: 126808", // memory + "v1.16.0 (Build: 6 Jun 2026)", // ver + }; + for (const char* s : successes) { + EXPECT_FALSE(Batch::cliReplyIsFailure(s)) << s; + } + // An empty reply is normalised to "OK" before it ever reaches the client. + EXPECT_FALSE(Batch::cliReplyIsFailure("")); + EXPECT_FALSE(Batch::cliReplyIsFailure(NULL)); +} + +TEST(WebConfigBatch, OnlyWritesGateTheDeferredReboot) { + // Writes gate it: these are what can leave a config not worth rebooting into. + EXPECT_TRUE(Batch::cliReplyGatesReboot("set tx 22")); + EXPECT_TRUE(Batch::cliReplyGatesReboot("set mqtt1.preset meshrank")); + EXPECT_TRUE(Batch::cliReplyGatesReboot("password hunter2")); + // Diagnostics do not. `memory` answering "Free: ..." must not be read as a + // failure and strand the operator's reboot, and a getter's "> value" must not + // be read as a success either — neither is asked. + EXPECT_FALSE(Batch::cliReplyGatesReboot("memory")); + EXPECT_FALSE(Batch::cliReplyGatesReboot("get tx")); + EXPECT_FALSE(Batch::cliReplyGatesReboot("reboot")); + EXPECT_FALSE(Batch::cliReplyGatesReboot("advert")); + EXPECT_FALSE(Batch::cliReplyGatesReboot(NULL)); + // "settle" must not be mistaken for a `set`; the space is part of the token. + EXPECT_FALSE(Batch::cliReplyGatesReboot("settle")); + + // A write counts only on the "OK" prefix every setter keeps. + EXPECT_TRUE(Batch::cliWriteSucceeded("OK")); + EXPECT_TRUE(Batch::cliWriteSucceeded("OK - reboot to apply")); + EXPECT_FALSE(Batch::cliWriteSucceeded("unknown config: nope")); + EXPECT_FALSE(Batch::cliWriteSucceeded("Error: expected a number")); + EXPECT_FALSE(Batch::cliWriteSucceeded("")); +} + +TEST(WebConfigBatch, CliRebootIsWithheldWhenAnyCommandInTheSequenceFailed) { + EXPECT_TRUE(Batch::cliRebootAllowed(/*has_reboot=*/true, /*all_ok=*/true)); + // Same rule a config save follows: do not reboot into a half-applied config + // over a link the operator may not get back. + EXPECT_FALSE(Batch::cliRebootAllowed(true, false)); + // No `reboot` in the sequence: nothing to allow either way. + EXPECT_FALSE(Batch::cliRebootAllowed(false, true)); + EXPECT_FALSE(Batch::cliRebootAllowed(false, false)); +} + // -------------------------------------------------------------------------- // Wrap-around guard shared with the production _reboot_at assignments // -------------------------------------------------------------------------- diff --git a/test/test_webconfig_keys/test_webconfig_keys.cpp b/test/test_webconfig_keys/test_webconfig_keys.cpp index ce78884b..3903453b 100644 --- a/test/test_webconfig_keys/test_webconfig_keys.cpp +++ b/test/test_webconfig_keys/test_webconfig_keys.cpp @@ -119,6 +119,45 @@ TEST(WebConfigKeys, EverySecretKeyIsAlsoAllowed) { } } +// ---- CLI secret reads ---------------------------------------------------- +// The web CLI runs commands with sender_timestamp 0, which is how CommonCLI +// recognises the serial console and answers secret getters in plaintext. These +// are the reads that must be masked back down for an HTTP caller. + +TEST(WebConfigKeys, MasksEverySecretReadTheCliCanReach) { + const char* masked[] = { + "get prv.key", // this node's identity — the worst one to leak + "get wifi.pwd", // grants the operator's LAN, not just the node + "get guest.password", + "get alert.psk", + "get bridge.secret", + "get mqtt1.password", "get mqtt1.token", + "get mqtt6.password", "get mqtt6.token", + "get wifi.pwd", // extra space after the verb + }; + for (const char* c : masked) EXPECT_TRUE(wcIsSecretReadCommand(c)) << c; +} + +TEST(WebConfigKeys, DoesNotMaskReadsThatCarryNoSecret) { + const char* plain[] = { + "get wifi.ssid", "get mqtt1.username", "get mqtt1.server", "get tx", + "get public.key", // public half, safe to read + "get mqtt.owner", // an owner's public key, not a credential + }; + for (const char* c : plain) EXPECT_FALSE(wcIsSecretReadCommand(c)) << c; +} + +TEST(WebConfigKeys, OnlyMasksReads) { + // Writing a secret has always been the portal's job and reveals nothing; + // only the read is restricted. Nor may a prefix be mistaken for a `get`. + EXPECT_FALSE(wcIsSecretReadCommand("set wifi.pwd hunter2")); + EXPECT_FALSE(wcIsSecretReadCommand("set prv.key aabb")); + EXPECT_FALSE(wcIsSecretReadCommand("password hunter2")); + EXPECT_FALSE(wcIsSecretReadCommand("getwifi.pwd")); + EXPECT_FALSE(wcIsSecretReadCommand("get")); + EXPECT_FALSE(wcIsSecretReadCommand("")); +} + // ---- request correlation ------------------------------------------------- TEST(WebConfigKeys, AcceptsExactHexRequestIds) { diff --git a/variants/heltec_t190/platformio.ini b/variants/heltec_t190/platformio.ini index 9e59d9a7..f24291e7 100644 --- a/variants/heltec_t190/platformio.ini +++ b/variants/heltec_t190/platformio.ini @@ -113,7 +113,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D MESH_PACKET_LOGGING=1 @@ -206,7 +205,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D MESH_PACKET_LOGGING=1 diff --git a/variants/heltec_tracker_v2/platformio.ini b/variants/heltec_tracker_v2/platformio.ini index 03169c05..46aeb1a3 100644 --- a/variants/heltec_tracker_v2/platformio.ini +++ b/variants/heltec_tracker_v2/platformio.ini @@ -187,7 +187,6 @@ build_flags = -D WITH_MQTT_BRIDGE=1 ; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y @@ -225,7 +224,6 @@ build_flags = -D WITH_MQTT_BRIDGE=1 ; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y @@ -264,7 +262,6 @@ build_flags = ; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y @@ -303,7 +300,6 @@ build_flags = ; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index 039126bb..9e9e0ea0 100644 --- a/variants/heltec_v3/platformio.ini +++ b/variants/heltec_v3/platformio.ini @@ -126,7 +126,6 @@ build_flags = -D WITH_MQTT_BRIDGE=1 ; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; Periodic 30s heap/pub-stats serial log — enable only for debugging (use `get mqtt.stats` on demand instead). @@ -212,7 +211,6 @@ build_flags = ; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -509,7 +507,6 @@ build_flags = -D WITH_MQTT_BRIDGE=1 ; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; Periodic 30s heap/pub-stats serial log — enable only for debugging (use `get mqtt.stats` on demand instead). @@ -561,7 +558,6 @@ build_flags = ; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; Keep default observer profile less verbose to reduce runtime contention. diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index 7b0e6b8e..ab063277 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -162,7 +162,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MQTT_MEMORY_DEBUG=1 @@ -213,7 +212,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MQTT_MEMORY_DEBUG=1 @@ -316,7 +314,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -359,7 +356,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/lilygo_t3s3/platformio.ini b/variants/lilygo_t3s3/platformio.ini index b0d72e35..2ba573c6 100644 --- a/variants/lilygo_t3s3/platformio.ini +++ b/variants/lilygo_t3s3/platformio.ini @@ -116,7 +116,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 ; -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -162,7 +161,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/lilygo_tbeam_1w/platformio.ini b/variants/lilygo_tbeam_1w/platformio.ini index db71d038..da354fe0 100644 --- a/variants/lilygo_tbeam_1w/platformio.ini +++ b/variants/lilygo_tbeam_1w/platformio.ini @@ -210,7 +210,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D PERSISTANT_GPS=1 -D ENV_SKIP_GPS_DETECT=1 @@ -257,7 +256,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D PERSISTANT_GPS=1 -D ENV_SKIP_GPS_DETECT=1 diff --git a/variants/lilygo_tbeam_SX1262/platformio.ini b/variants/lilygo_tbeam_SX1262/platformio.ini index 56208b62..7728af30 100644 --- a/variants/lilygo_tbeam_SX1262/platformio.ini +++ b/variants/lilygo_tbeam_SX1262/platformio.ini @@ -161,7 +161,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 ; -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -204,7 +203,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/lilygo_tbeam_SX1276/platformio.ini b/variants/lilygo_tbeam_SX1276/platformio.ini index 4e28c3eb..a17e86ff 100644 --- a/variants/lilygo_tbeam_SX1276/platformio.ini +++ b/variants/lilygo_tbeam_SX1276/platformio.ini @@ -160,7 +160,6 @@ build_flags = -D MAX_NEIGHBOURS=50 -D PERSISTANT_GPS=1 -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 ; -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -203,7 +202,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini index 384d689d..d509c4f7 100644 --- a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini +++ b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini @@ -121,7 +121,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D MESH_PACKET_LOGGING=1 @@ -164,7 +163,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/lilygo_tlora_v2_1/platformio.ini b/variants/lilygo_tlora_v2_1/platformio.ini index 9bb86dd9..cc3df6ca 100644 --- a/variants/lilygo_tlora_v2_1/platformio.ini +++ b/variants/lilygo_tlora_v2_1/platformio.ini @@ -167,7 +167,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_TASK_STACK_SIZE=16384 -D ESP32_CPU_FREQ=240 @@ -213,7 +212,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_TASK_STACK_SIZE=16384 -D ESP32_CPU_FREQ=240 diff --git a/variants/rak3112/platformio.ini b/variants/rak3112/platformio.ini index 36432af4..ea797cab 100644 --- a/variants/rak3112/platformio.ini +++ b/variants/rak3112/platformio.ini @@ -116,7 +116,6 @@ build_flags = -D WITH_MQTT_BRIDGE=1 ; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D MESH_PACKET_LOGGING=1 @@ -185,7 +184,6 @@ build_flags = ; Non-PSRAM board: neighbors table costs ~4 KB of internal DRAM (see MQTTBridge.h). -D MQTT_NEIGHBORS_WITHOUT_PSRAM=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/station_g2/platformio.ini b/variants/station_g2/platformio.ini index 9d84aaf4..15e23321 100644 --- a/variants/station_g2/platformio.ini +++ b/variants/station_g2/platformio.ini @@ -188,7 +188,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 @@ -315,7 +314,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 # -D MESH_PACKET_LOGGING=1 diff --git a/variants/station_g3_esp32/platformio.ini b/variants/station_g3_esp32/platformio.ini index d50e05c0..02d0aa76 100644 --- a/variants/station_g3_esp32/platformio.ini +++ b/variants/station_g3_esp32/platformio.ini @@ -176,7 +176,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 # -D MESH_PACKET_LOGGING=1 @@ -224,7 +223,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/thinknode_m7/platformio.ini b/variants/thinknode_m7/platformio.ini index bf1e4435..7da14112 100644 --- a/variants/thinknode_m7/platformio.ini +++ b/variants/thinknode_m7/platformio.ini @@ -198,7 +198,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 # -D MESH_PACKET_LOGGING=1 @@ -244,7 +243,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 # -D MESH_PACKET_LOGGING=1 diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index d50ed546..78c582d9 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -111,7 +111,6 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_BRIDGE=1 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 -D MESH_PACKET_LOGGING=1 @@ -155,7 +154,6 @@ build_flags = -D ROOM_PASSWORD='"hello"' -D WITH_MQTT_BRIDGE=1 -D MAX_NEIGHBOURS=50 - -D MAX_MQTT_BROKERS=3 -D MQTT_MAX_PACKET_SIZE=1024 -D MQTT_DEBUG=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/webui/index.html b/webui/index.html index c634d78f..ab7b43d6 100644 --- a/webui/index.html +++ b/webui/index.html @@ -27,6 +27,10 @@ header svg{flex:none} .hmeta div{font-size:12px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .badge{margin-left:auto;flex:none;font-size:11px;font-weight:650;padding:3px 9px;border-radius:99px;background:var(--chip);color:var(--mut)} .badge.setup{background:#f3e8d3;color:#8a5c00} +.hicon{flex:none;margin-left:8px;padding:3px 8px;border:1px solid var(--in-line);border-radius:7px; + background:var(--in-bg);color:var(--mut);cursor:pointer;line-height:1.3; + font:700 12px/1.3 ui-monospace,Menlo,Consolas,monospace} +.hicon:hover{color:var(--acc);border-color:var(--acc)} @media (prefers-color-scheme:dark){.badge.setup{background:#3a2f14;color:#e0b45c}} main{max-width:640px;margin:0 auto;padding:16px} .card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:var(--shadow);margin-bottom:14px} @@ -118,6 +122,86 @@ canvas{width:100%;height:56px;display:block} .kv b{font-weight:600;text-align:right;overflow:hidden;text-overflow:ellipsis} .hide{display:none!important} .note{font-size:12.5px;color:var(--mut);background:var(--chip);border-radius:8px;padding:9px 11px;margin-bottom:13px} + +/* ---------------- CLI terminal ---------------- + Deliberately not themed: a console reads as a console in either colour + scheme, and the reply colours below are tuned against this one background. */ +.term{--tf:#d7e0ea;--tdim:#6d7c8f;--tacc:#58a6ff;--tgrn:#57c26e;--terr:#ff7b72;--twarn:#e3b341; + background:#0b0e12;color:var(--tf);border:1px solid #202832;border-radius:12px; + font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace; + font-size:13px;line-height:1.5;display:flex;flex-direction:column;overflow:hidden; + /* generous by design: cliFit() clamps this down to the room actually left + below the tabs, so the terminal fills the screen without scrolling it */ + height:78vh;min-height:240px} +/* >=16px stops iOS zooming the page when the prompt takes focus (same reason the + form inputs are 16px) — and on a phone 16px mono is the readable size anyway. */ +@media(pointer:coarse){.term{font-size:16px}} +/* body's bottom padding reserves room for the save bar so it can't cover the + last card. The CLI tab sizes itself around the save bar instead (cliFit), so + the reservation there would only add dead space below the terminal. */ +body.tab-cli{padding-bottom:0} +.term-hd{display:flex;align-items:center;gap:8px;flex:none;padding:6px 10px; + background:#11161d;border-bottom:1px solid #202832;font-size:.82em;color:var(--tdim)} +.term-hd .sp{flex:1} +.term-hd button{flex:none;background:none;border:1px solid #2a333f;border-radius:5px; + color:var(--tdim);font:inherit;padding:1px 8px;cursor:pointer} +.term-hd button:hover{color:var(--tf);border-color:#3d4a5a} +.term-out{flex:1;overflow-y:auto;overflow-x:hidden;padding:10px;-webkit-overflow-scrolling:touch} +.term-out>div{white-space:pre-wrap;word-break:break-word} +.term-out .cmd{color:#fff} +.term-out .cmd:before{content:"> ";color:var(--tgrn)} +/* A reply is green because it succeeded; red is reserved for a node that said + no. Anything else reads every `get` as a failure. */ +.term-out .rep{color:#7ee787} +.term-out .err{color:var(--terr)} +.term-out .sys{color:var(--tdim)} +.term-out .gap{height:.55em} +/* zero-height rail: the suggestion list hangs off it and overlays the output + instead of reflowing it, so the line you are typing never moves */ +.term-anchor{position:relative;height:0;flex:none;z-index:2} +/* --sugmax is set from the terminal's own height (see cliFit) so the list can + never be taller than the box it hangs inside and lose rows off the top */ +.term-sug{position:absolute;left:0;right:0;bottom:0;max-height:var(--sugmax,44vh);overflow-y:auto; + background:#0e131a;border-top:1px solid #202832;box-shadow:0 -10px 24px rgba(0,0,0,.55)} +.sg{display:flex;gap:10px;align-items:baseline;padding:5px 10px;cursor:pointer} +.sg b{flex:none;font-weight:500;color:#cfe3ff;white-space:pre} +.sg b u{color:var(--tacc);font-weight:700;text-decoration:none} +.sg i{min-width:0;font-style:normal;font-size:.84em;color:var(--tdim); + overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.sg.on{background:#1e2b3a;box-shadow:inset 3px 0 0 var(--tacc)} +.sg.on b{color:#fff} +.sg.on i{color:#8b9bad} +.term-line{display:flex;align-items:center;gap:8px;flex:none;padding:8px 10px; + background:#0e131a;border-top:1px solid #202832} +.term-ps{flex:none;color:var(--tgrn)} +#term-in{flex:1;min-width:0;padding:0;border:0;border-radius:0;background:none;color:#fff; + font:inherit;outline:none;caret-color:var(--tgrn)} +#term-in:focus{border:0;box-shadow:none} +#term-in::placeholder{color:#495767} +#term-in:disabled{color:var(--tdim)} +.term-go{flex:none;padding:2px 10px;border:1px solid #2a333f;border-radius:6px; + background:none;color:var(--tdim);font:inherit;cursor:pointer} +.term-go:hover{color:var(--tgrn);border-color:#3d4a5a} +.term-go:disabled{opacity:.4;cursor:default} +/* pasted-sequence confirmation, rendered in the scrollback rather than as a + modal so it reads as part of the session (and behaves on a phone) */ +.term-cfm{border:1px solid #2a333f;border-left:3px solid var(--twarn);border-radius:8px; + background:#12181f;padding:8px 10px;margin:6px 0} +.term-cfm .h{color:var(--twarn);margin-bottom:5px} +/* --n is the width of the "12 " gutter: hang the wrap so a long command's + continuation lines up under the command, not under its number */ +.term-cfm .ln{color:#a9b7c6;white-space:pre-wrap;word-break:break-word; + padding-left:var(--n,3ch);text-indent:calc(-1 * var(--n,3ch))} +.term-cfm .ln s{text-decoration:none;color:var(--tdim)} +.term-cfm .w{color:var(--twarn);margin-top:5px;font-size:.9em} +.term-cfm .btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px} +.term-cfm button{border:1px solid #2a333f;border-radius:6px;background:#1b2530;color:var(--tf); + font:inherit;padding:4px 12px;cursor:pointer} +.term-cfm button.go{background:#1d3b28;border-color:#2f6b45;color:#8fe0a5} +.term-cfm button:disabled{opacity:.45;cursor:default} +.cli-tip{font-size:12px;color:var(--mut);margin-top:9px;display:flex;gap:12px;flex-wrap:wrap} +.cli-tip kbd{font:inherit;font-family:ui-monospace,Menlo,monospace;background:var(--chip); + border-radius:4px;padding:0 4px} @@ -131,6 +215,12 @@ canvas{width:100%;height:56px;display:block}
connecting…
+ +
@@ -249,6 +339,7 @@ canvas{width:100%;height:56px;display:block} +
@@ -410,6 +501,33 @@ canvas{width:100%;height:56px;display:block}
No data yet.
+ +
+
+
+
+ + + + +
+
+
+
+ > + + +
+
+
+ Tab complete history + Esc dismisspaste multiple lines to run a sequence +
+
+
@@ -464,6 +582,18 @@ function $(s){return document.querySelector(s)} function $$(s){return Array.prototype.slice.call(document.querySelectorAll(s))} function toast(m){var t=$("#toast");t.textContent=m;t.classList.add("show");clearTimeout(t._h);t._h=setTimeout(function(){t.classList.remove("show")},2600)} function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{"&":"&","<":"<",">":">",'"':"""}[c]})} +// 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 +// tag is the same on every observer build and the commit is for machines. `ver` +// still prints the whole string when you want it. +function shortVer(){ + var fw=st.fw||"",m=/^(v?\d+\.\d+\.\d+(?:\.\d+)?)/.exec(fw); + var s=m?m[1]:(fw||"unknown version"); + if(/-dev(-|$)/.test(fw))s+="-dev"; // OTA_CHANNEL_TAG=beta-dev + else if(/-beta(-|$)/.test(fw))s+="-beta"; // a beta-only channel, if ever + return st.build?s+" ("+st.build+")":s; +} function api(path,opts){ opts=opts||{}; @@ -476,7 +606,14 @@ function api(path,opts){ } return fetch(path,opts).then(function(r){ if(r.status===401){showLogin();throw new Error("auth")} - return r.json().then(function(j){ + // An error response need not carry JSON — a bare 404 from handleNotFound + // has an empty body. Letting the parse failure escape would strip the HTTP + // status off the error and leave callers unable to tell "no such endpoint" + // from "the network dropped". Successful responses must still parse. + return r.json().catch(function(){ + if(r.ok)throw new Error("unreadable reply from the node"); + return {}; + }).then(function(j){ if(!r.ok&&r.status!==202){ // Carry the HTTP status and any batch reqid so callers can tell a // definite rejection (400/409/413) from an ambiguous network failure. @@ -503,10 +640,15 @@ 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.needsSetup=!!s.needs_setup;st.nslots=s.active_slots||s.runtime_slots||6; + if(s.max_cmds>0)CLI_MAX=s.max_cmds; + st.fw=s.fw||"";st.build=s.build_date||"";st.role=s.role||"";st.board=s.board||""; $("#h-name").textContent=s.name||"MeshCore"; - $("#h-sub").textContent=s.role+" · "+s.fw+" · "+s.board; + $("#h-sub").textContent=s.role+" · "+shortVer()+" · "+s.board; var b=$("#h-badge");b.classList.remove("hide"); if(s.mode==="setup"){b.textContent="SETUP";b.classList.add("setup")}else{b.textContent="LAN"} + // Only in setup mode: in LAN mode the console is a tab, and the header is + // also on screen before login, where no shortcut into it belongs. + $("#h-console").classList.toggle("hide",s.mode!=="setup"); return api("/api/presets").catch(function(){return{presets:[]}}); }).then(function(p){ st.presets=p.presets||[]; @@ -518,7 +660,9 @@ function boot(){ }); } -function show(v){["#v-login","#v-wizard","#v-app"].forEach(function(id){$(id).classList.add("hide")});$(v).classList.remove("hide");updateSaveBar()} +function show(v){["#v-login","#v-wizard","#v-app"].forEach(function(id){$(id).classList.add("hide")});$(v).classList.remove("hide"); + document.body.classList.remove("tab-cli"); // re-armed by the tab click when the CLI is the one shown + updateSaveBar()} function showLogin(){show("#v-login")} /* ---------- login ---------- */ @@ -963,11 +1107,22 @@ function enterApp(){ show("#v-app"); loadConfig().catch(function(e){if(e.message!=="auth")toast("Failed to load config")}); } +// Setup-mode shortcut: straight past the guided steps into the terminal, for +// operators who already have a config to paste. Setup mode authenticates by +// proximity to the AP, so this hands the whole command surface to anyone in +// range — the same trust the wizard already extends, since it can set the admin +// password and reflash the node's identity too. +function enterConsole(){ + enterApp(); + $('#tabs button[data-t="cli"]').click(); +} $("#tabs").addEventListener("click",function(ev){ var b=ev.target.closest("button");if(!b)return; $$("#tabs button").forEach(function(x){x.classList.toggle("on",x===b)}); - ["radio","mqtt","wifi","stats"].forEach(function(t){$("#t-"+t).classList.toggle("hide",t!==b.dataset.t)}); + ["radio","mqtt","wifi","stats","cli"].forEach(function(t){$("#t-"+t).classList.toggle("hide",t!==b.dataset.t)}); if(b.dataset.t==="stats")startStats();else stopStats(); + document.body.classList.toggle("tab-cli",b.dataset.t==="cli"); + if(b.dataset.t==="cli")enterCli(); }); /* ---------- stats ---------- */ @@ -1285,6 +1440,665 @@ function showReboot(msg,reconnect,title){ },1000); } +/* ---------- CLI: command reference ---------- + Drives autocomplete only — the node remains the authority on what it accepts. + A trailing space marks a command that takes an argument, so accepting the + completion leaves the cursor ready for the value. */ +// Commands per submitted sequence. The node reports its own limit (MAX_BATCH, +// the fixed slot the CLI shares with config saves) in /api/status; this is only +// the fallback for firmware that doesn't say. +var CLI_MAX=24; +// [command, description] +var CLI_VERBS=[ + ["ver","Firmware version"], + ["board","Board and hardware info"], + ["clock","Show the device clock (UTC)"], + // `clock sync` is absent on purpose: it takes its time from the caller's + // timestamp, which a web request has none of, so it can only ever fail here. + ["time ","Set the clock {epoch-seconds}"], + ["region","Show the configured region"], + ["memory","Heap and PSRAM free/min/largest block"], + ["neighbors","Nodes heard recently, with RSSI and age"], + ["neighbor.remove ","Drop one neighbour {64-hex-char-key}"], + // Handled by MyMesh before it delegates to CommonCLI — a whole second + // command surface the table missed until discover.* turned up absent. + ["discover.neighbors","Ask neighbours to identify themselves"], + ["discover.scopes","Collect neighbour scopes (needs the neighbors build)"], + ["setperm ","Set a node's ACL permissions {64-hex-char-key} {int8}"], + ["advert","Send an advert now (flooded)"], + ["advert.zerohop","Send an advert neighbours will not repeat"], + ["tempradio ","Try radio params without saving {freq,bw,sf,cr}"], + ["clear stats","Reset the packet and radio counters"], + ["stats-core","Core counters (recv, sent, airtime)"], + ["stats-packets","Per-packet-type counters"], + ["stats-radio","Radio counters (RSSI, SNR, noise)"], + ["stats-radio-diag","Extended radio diagnostics"], + // Bare `log` is absent: it streams the file to the serial console and stalls + // the radio doing it, and hands back only "EOF". + ["log start","Start packet logging to the filesystem"], + ["log stop","Stop packet logging"], + ["log erase","Delete the stored packet logs"], + ["sensor list","List attached sensors"], + ["sensor get ","Read one sensor {index}"], + ["sensor set ","Write one sensor {index} {value}"], + ["gps on","Power up the GPS"], + ["gps off","Power down the GPS"], + ["gps sync","Set the clock and location from the GPS"], + ["gps setloc","Copy the current GPS fix into lat/lon"], + ["gps advert none","Do not include GPS position in adverts"], + ["gps advert share","Advertise the live GPS position"], + ["gps advert prefs","Advertise the stored lat/lon"], + ["powersaving","Show the power-saving mode"], + ["powersaving on","Enable power saving"], + ["powersaving off","Disable power saving"], + ["password ","Change the admin password {new-password}"], + ["alert test","Send a test alert on the configured channel"], + ["ota check","Check for a newer build (does not flash)"], + ["ota update","Download and flash the newer build, then reboot"], + // `start ota` is absent: it binds port 80, which this portal is already using. + ["start webconfig","Start this portal on the LAN"], + ["start webconfig ap","Start this portal on its own setup AP"], + ["stop webconfig","Stop this portal"], + ["reboot","Restart the node"], + ["clkreboot","Restart the node, preserving the clock"], + ["poweroff","Power the node off"], + ["shutdown","Power the node off (same as poweroff)"], + ["erase","Erase the filesystem — settings and identity"] +]; +/* [key, description, mode, values] + mode: 0 = get and set, 1 = get only, 2 = set only + values: enum offered as value completions after `set ` */ +var CLI_KEYS=[ + ["name","Node name",0], + ["lat","Advert latitude",0], + ["lon","Advert longitude",0], + ["public.key","This node's public key",1], + // `get acl` is absent: it prints to the serial console and returns nothing. + ["prv.key","Restore an identity {64-hex-char-key}",2], + ["role","Node role",1], + ["radio","Radio parameters {freq,bw,sf,cr}",0], + ["freq","Frequency in MHz",0], + ["tx","TX power in dBm",0], + ["af","Airtime factor",0], + ["dutycycle","Duty cycle percent (writes airtime factor)",0], + ["cad","Listen before transmit",0,"on|off"], + ["radio.rxgain","SX126x RX boosted gain",0,"on|off"], + ["radio.fem.rxgain","Front-end module RX gain",0], + ["radio.watchdog","Restart the radio if silent this long {0-120 min}",0], + ["int.thresh","Interference threshold",0], + ["agc.reset.interval","AGC reset interval in seconds",0], + ["rxdelay","RX delay base",0], + ["txdelay","TX delay factor {0-2}",0], + ["direct.txdelay","TX delay factor for direct packets {0-2}",0], + ["repeat","Forward mesh traffic",0,"on|off"], + ["multi.acks","Extra ACKs to send",0], + ["allow.read.only","Allow read-only remote access",0,"on|off"], + ["advert.interval","Local advert interval in minutes (0 = off)",0], + ["flood.advert.interval","Flood advert interval in hours (0 = off)",0], + ["flood.max","Max flood hops {0-64}",0], + ["flood.max.advert","Max advert hops {0-64}",0], + ["flood.max.unscoped","Max hops for unscoped floods {0-64}",0], + ["loop.detect","Drop floods already carrying this node's hash",0,"off|minimal|moderate|strict"], + ["path.hash.mode","Path hash mode {0|1|2}",0], + ["owner.info","Owner info text (| becomes a newline)",0], + ["guest.password","Guest password",0], + ["adc.multiplier","Battery ADC multiplier",0], + ["wifi.ssid","WiFi network name",0], + ["wifi.pwd","WiFi password",0], + ["wifi.powersave","WiFi power-save mode",0,"none|min|max"], + ["wifi.status","WiFi connection, IP, RSSI and uptime",1], + ["mqtt.origin","Observer name in published messages",0], + ["mqtt.iata","IATA region code used in topic paths",0], + ["mqtt.status","Per-slot connection status",1], + ["mqtt.stats","Publish/queue counters",1], + ["mqtt.presets","Available broker presets",1], + ["mqtt.config.valid","Whether the MQTT config is usable",1], + ["mqtt.packets","Publish packet messages",0,"on|off"], + ["mqtt.raw","Also publish full raw frames",0,"on|off"], + ["mqtt.rx","Publish packets heard over the air",0,"on|off"], + ["mqtt.tx","Publish packets this node sends",0,"off|on|advert"], + ["mqtt.interval","Status publish interval {1-60 min}",0], + ["mqtt.neighbors","Publish the neighbour table (PSRAM boards)",0,"on|off"], + ["mqtt.neighbors.interval","Neighbour publish interval {12-336 hours}",0], + ["mqtt.owner","Owner public key {64-hex-char-key}",0], + ["mqtt.email","Owner email address",0], + ["mqtt.ntp","NTP server (none clears it)",0], + ["mqtt.ntp.diag","Last NTP sync result",1], + ["timezone","POSIX timezone string",0], + ["timezone.offset","UTC offset in hours {-12 to 14}",0], + ["snmp","SNMP agent (restart required)",0,"on|off"], + ["snmp.community","SNMP community string",0], + ["alert","Alert channel",0,"on|off"], + ["alert.psk","Alert channel pre-shared key",0], + ["alert.hashtag","Alert channel hashtag",0], + ["alert.region","Alert region filter",0], + ["alert.interval","Minimum minutes between alerts",0], + ["alert.mqtt","Send alerts to MQTT",0,"on|off"], + ["alert.wifi","Alert on WiFi problems",0,"on|off"], + ["bridge.enabled","Serial packet bridge",0,"on|off"], + ["bridge.source","Packets the bridge carries",0,"rx|tx"], + ["bridge.baud","Bridge serial baud rate",0], + ["bridge.delay","Bridge send delay",0], + ["bridge.channel","Bridge channel",0], + ["bridge.secret","Bridge shared secret",0] +]; +// Per-slot keys, expanded across the slots this board actually runs. +// [field, description, values] +var CLI_SLOT=[ + ["preset","preset",1], + ["server","custom broker hostname"], + ["port","broker port {1-65535}"], + ["username","username"], + ["password","password"], + ["token","token (required by some presets)"], + ["topic","custom topic template, e.g. {iata}/{device}/{type}"], + ["audience","JWT audience — enables Ed25519 auth, blank clears"], + ["filter","packet types to publish: all, none, or a CSV of names/0-15"] +]; +var CLI_TYPES="req,response,txt_msg,ack,advert,grp_txt,grp_data,anon_req,path,trace,multipart,control,raw_custom".split(","); + +var cli={built:0,tbl:[],hist:[],hix:-1,draft:"",sug:[],sel:-1,busy:false,shown:false,pwd:false}; + +// Rebuilt when the slot count changes: `active_slots` decides how many +// mqttN.* keys actually exist on this board. +function cliTable(){ + if(cli.built===st.nslots)return cli.tbl; + var t=CLI_VERBS.slice(); + CLI_KEYS.forEach(function(k){ + var vals=k[3]?" {"+k[3]+"}":""; + if(k[2]!==2)t.push(["get "+k[0],k[1]]); + if(k[2]!==1)t.push(["set "+k[0]+" ",k[1]+vals]); + }); + for(var n=1;n<=st.nslots;n++){ + CLI_SLOT.forEach(function(f){ + t.push(["get mqtt"+n+"."+f[0],"Slot "+n+" "+f[1]]); + t.push(["set mqtt"+n+"."+f[0]+" ","Slot "+n+" "+f[1]]); + }); + } + cli.tbl=t;cli.built=st.nslots; + return t; +} +// Value completions for `set `. Returns null when the key has no enum, +// which is also how the command list is suppressed once a value is being typed. +function cliEnum(key){ + var m=key.match(/^mqtt[1-9]\.(\w+)$/); + if(m){ + if(m[1]==="preset")return st.presets.map(function(p){return p.name}).concat(["custom","none"]); + if(m[1]==="filter")return ["all","none"].concat(CLI_TYPES); + return null; + } + for(var i=0;i=out.scrollHeight-24; + var d=document.createElement("div"); + d.className=cls;d.textContent=text; + out.appendChild(d); + while(out.childNodes.length>400)out.removeChild(out.firstChild); + if(atEnd)out.scrollTop=out.scrollHeight; + return d; +} +function cliGap(){var d=cliEcho("gap","");return d} +function cliClear(){$("#term-out").innerHTML="";cliBanner();$("#term-in").focus()} +function cliBanner(){ + cliEcho("sys","MeshCore "+shortVer()); + cliEcho("sys",(st.role||"")+" · "+(st.board||"")+ + (/^v?\d+\.\d+\.\d+$/.test(st.fw||"")?" · local build, OTA not configured":"")); + if(st.mode==="setup"){ + // The wizard refuses to finish without an admin password; nothing stops a + // console-driven setup from rebooting on the factory one, so say so here + // rather than only warning at the point of reboot. + cliEcho("sys","Setup mode — paste a prepared config, then finish with "+ + "\"password \" and \"reboot\". \"← setup\" returns to the guided steps."); + } + cliEcho("sys","Type help for a summary, or a prefix and Tab to complete."); +} +function cliHelp(){ + cliGap(); + cliEcho("cmd","help"); + cliEcho("rep", + "ver / board / clock identity, firmware and time\n"+ + "get read any setting\n"+ + "set change any setting\n"+ + "advert send an advert now\n"+ + "neighbors nodes heard recently\n"+ + "memory / stats-core health counters\n"+ + "get mqtt.status per-slot broker connections\n"+ + "reboot restart the node"); + cliEcho("sys","Tab completes any prefix ("+cliTable().length+" commands known). "+ + "Paste several lines to run them in sequence. clear empties this window."); + $("#term-in").focus(); +} + +/* ---------- CLI: autocomplete ---------- */ +function cliSugOpen(){return !$("#term-sug").classList.contains("hide")} +function cliHideSug(){$("#term-sug").classList.add("hide");$("#term-sug").innerHTML="";cli.sug=[];cli.sel=-1} +function cliLoose(hay,q){ + var parts=q.split(/\s+/); + for(var i=0;i ` is complete, complete the VALUE. Keys without an enum +// return an empty list so the command rows don't reappear under a typed value. +function cliValueMatch(s){ + var m=s.match(/^set\s+(\S+)\s+(.*)$/); + if(!m)return null; + var vals=cliEnum(m[1]); + if(!vals)return{list:[],pre:0}; + var head=s.slice(0,s.length-m[2].length),typed=m[2]; + // filter lists are CSV: complete the segment after the last comma + var cut=typed.lastIndexOf(","); + if(cut>=0){head+=typed.slice(0,cut+1);typed=typed.slice(cut+1)} + var lo=typed.toLowerCase(),out=[]; + vals.forEach(function(v){ + if(v.toLowerCase().indexOf(lo)===0)out.push([head+v,cliValueNote(m[1],v)]); + }); + return{list:out,pre:head.length+typed.length}; +} +// The value is already the row's label, so the second column only earns its +// place when it says something the value doesn't. +function cliValueNote(key,val){ + if(/^mqtt[1-9]\.preset$/.test(key)){ + for(var i=0;i'+(m.pre?""+head+"":head)+tail+""+ + (e[1]?''+esc(e[1])+"":"")+""; + }); + box.innerHTML=h;box.classList.remove("hide");box.scrollTop=0; +} +function cliPaint(){ + $$("#term-sug .sg").forEach(function(el,i){ + var on=i===cli.sel;el.classList.toggle("on",on); + if(on&&el.scrollIntoView)el.scrollIntoView({block:"nearest"}); + }); +} +function cliMove(d){ + if(!cli.sug.length)return; + cli.sel=cli.sel<0?(d>0?0:cli.sug.length-1):(cli.sel+d+cli.sug.length)%cli.sug.length; + cliPaint(); +} +function cliAccept(i){ + var e=cli.sug[i];if(!e)return; + var inp=$("#term-in"); + inp.value=e[0];inp.focus(); + cli.hix=-1; + cliSug(); // re-filter: a command that takes an argument now offers values +} +function cliTabKey(){ + if(!cli.sug.length){cliSug();if(!cli.sug.length)return} + if(cli.sel>=0){cliAccept(cli.sel);return} + // shell behaviour: extend to the longest prefix every match shares before + // committing to any single one + var cur=$("#term-in").value.replace(/^\s+/,""),lcp=cli.sug[0][0]; + cli.sug.forEach(function(e){ + var n=0;while(ncur.length){$("#term-in").value=lcp;cliSug();return} + cliAccept(0); +} +$("#term-sug").addEventListener("mousedown",function(ev){ev.preventDefault()}); +$("#term-sug").addEventListener("click",function(ev){ + var r=ev.target.closest(".sg");if(!r)return; + cliAccept(+r.dataset.i); +}); + +/* ---------- CLI: history ---------- + In memory for the session only, never localStorage: `set wifi.pwd …` and + `password …` pass through here and must not outlive the tab. */ +function cliPush(line){ + if(cli.hist[cli.hist.length-1]!==line)cli.hist.push(line); + if(cli.hist.length>60)cli.hist.shift(); + cli.hix=-1; +} +function cliHistMove(d){ + var inp=$("#term-in"); + if(!cli.hist.length)return; + if(cli.hix<0){cli.draft=inp.value;cli.hix=cli.hist.length} + cli.hix+=d; + if(cli.hix<0)cli.hix=0; + if(cli.hix>=cli.hist.length){cli.hix=-1;inp.value=cli.draft} + else inp.value=cli.hist[cli.hix]; + cliHideSug(); + var n=inp.value.length; + try{inp.setSelectionRange(n,n)}catch(e){} +} + +/* ---------- CLI: input ---------- */ +$("#term-in").addEventListener("input",function(){cli.hix=-1;cliSug()}); +$("#term-in").addEventListener("blur",function(){setTimeout(cliHideSug,120)}); +$("#term-in").addEventListener("focus",function(){cliFit()}); +$("#term-in").addEventListener("keydown",function(ev){ + var k=ev.key; + if(k==="Tab"){ev.preventDefault();cliTabKey();return} + if(k==="ArrowUp"||k==="ArrowDown"){ + var d=k==="ArrowDown"?1:-1; + ev.preventDefault(); + // The list owns the arrows while it is open; history takes them back once + // it is dismissed, which is what Esc is for. + if(cliSugOpen())cliMove(d);else cliHistMove(d); + return; + } + if(k==="Enter"){ + ev.preventDefault(); + // Enter runs what is typed, unless a suggestion was deliberately selected + // with the arrows — then it accepts, and a second Enter runs. + if(cliSugOpen()&&cli.sel>=0){cliAccept(cli.sel);return} + cliSubmit();return; + } + if(k==="Escape"){ + if(cliSugOpen()){cliHideSug();return} + this.value="";cli.hix=-1;return; + } + if((k==="l"||k==="L")&&ev.ctrlKey){ev.preventDefault();cliClear()} +}); +// Multi-line paste: a pasted list is a sequence to confirm, not a line to +// mangle. (A single-line paste falls through to the browser's own insert.) +$("#term-in").addEventListener("paste",function(ev){ + var cb=ev.clipboardData||window.clipboardData; + if(!cb)return; + 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(!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 cliParse(text){ + var out=[]; + text.split(/\r\n|\r|\n/).forEach(function(raw){ + var l=raw.trim(); + if(!l||l.charAt(0)==="#")return; // blanks and comments + l=l.replace(/^(?:[>$]|meshcore\s*[>$#])\s+/,""); // tolerate a pasted transcript + if(l)out.push(l); + }); + return out; +} + +/* ---------- CLI: confirmation ---------- + Rendered into the scrollback rather than as a modal: it reads as part of the + session, and on a phone it can't end up behind the keyboard. */ +// Anchored the way CommonCLI dispatches, which is mostly on a PREFIX: `reboot` +// matches the first six bytes, so `reboot now` reboots too. Matching these +// exactly (/^reboot$/) let those variants skip the confirmation entirely. +var CLI_RISK=[ + [/^erase$/,"erases the filesystem — stored settings and this node's identity"], + [/^clkreboot/,"resets the clock and restarts the node"], + [/^reboot/,"restarts the node"], + [/^(poweroff|shutdown)/,"powers the node off"], + [/^ota update/,"downloads and flashes new firmware, then reboots"], + [/^stop webconfig$/,"stops this portal"], + [/^set wifi\.(ssid|pwd)\s/,"changes WiFi — this page will drop"], + [/^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"], + [/^set prv\.key\s/,"replaces this node's identity"] +]; +function cliRisks(cmds){ + var seen={},out=[]; + cmds.forEach(function(c){ + CLI_RISK.forEach(function(r){ + if(r[0].test(c)&&!seen[r[1]]){seen[r[1]]=1;out.push(r[1])} + }); + }); + return out; +} +function cliConfirm(cmds){ + var over=cmds.length>CLI_MAX; + if(over)cmds=cmds.slice(0,CLI_MAX); + var risks=cliRisks(cmds),one=cmds.length===1; + var out=$("#term-out"),box=document.createElement("div"); + box.className="term-cfm"; + var h=document.createElement("div");h.className="h"; + h.textContent=one?"Run this command?":("Run these "+cmds.length+" commands in order?"); + box.appendChild(h); + var w=String(cmds.length).length; + box.style.setProperty("--n",(w+2)+"ch"); + cmds.forEach(function(c,i){ + var d=document.createElement("div");d.className="ln"; + var n=document.createElement("s"); + n.textContent=(new Array(w-String(i+1).length+1)).join(" ")+(i+1)+" "; + d.appendChild(n);d.appendChild(document.createTextNode(c)); + box.appendChild(d); + }); + if(over){ + var t=document.createElement("div");t.className="w"; + t.textContent="Only the first "+CLI_MAX+" lines are included — the rest were dropped."; + box.appendChild(t); + } + if(risks.length){ + var r=document.createElement("div");r.className="w"; + r.textContent="⚠ This "+risks.join("; and ")+"."; + box.appendChild(r); + } + // Onboarding by paste skips the wizard's mandatory admin-password step, and + // rebooting is what commits the node to normal operation. + if(st.mode==="setup"&&!cli.pwd&&cmds.some(function(c){return /^(reboot|clkreboot)$/.test(c)})){ + var p=document.createElement("div");p.className="w"; + // The node enforces this too (handleCliPost) — this is the earlier, kinder + // half of the same rule, so the operator finds out before sending. + p.textContent="⚠ Set the admin password first: run \"password \". "+ + "The node will refuse to finish setup on the factory one."; + box.appendChild(p); + } + var btns=document.createElement("div");btns.className="btns"; + var go=document.createElement("button");go.className="go"; + go.textContent=one?"Run":("Run "+cmds.length+" commands"); + var no=document.createElement("button");no.textContent="Cancel"; + btns.appendChild(go);btns.appendChild(no);box.appendChild(btns); + function settle(text){ + btns.remove(); + var s=document.createElement("div");s.className="w";s.textContent=text;box.appendChild(s); + } + go.onmousedown=no.onmousedown=function(ev){ev.preventDefault()}; + go.onclick=function(){settle(one?"Running…":"Running "+cmds.length+" commands…");cliRun(cmds)}; + no.onclick=function(){settle("Cancelled — nothing was sent.");$("#term-in").focus()}; + out.appendChild(box);out.scrollTop=out.scrollHeight; + $("#term-in").blur(); // the sequence needs an answer before more typing +} + +/* ---------- CLI: run ---------- + Same 202 + reqid + poll contract as a config save, and for the same reason: + the commands run on the node's main loop, not inside the request. Results + 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(); + cliHideSug(); + if(!line)return; + cliPush(line);inp.value=""; + if(line==="clear"||line==="cls"){cliClear();return} + if(line==="help"||line==="?"){cliHelp();return} + if(cliRisks([line]).length){cliConfirm([line]);return} + cliRun([line]); +} +function cliBusy(on){ + cli.busy=on; + $("#term-in").disabled=on; + $(".term-go").disabled=on; + if(!on&&window.matchMedia("(pointer:fine)").matches)$("#term-in").focus(); +} +function cliRun(cmds){ + cliBusy(true); + cliGap(); + var reqid=mkReqId(); + var status=cliEcho("sys",cmds.length>1?"running 0/"+cmds.length+"…":"…"); + post("/api/cli",{reqid:reqid,cmds:cmds}).then(function(){ + cliPoll(reqid,cmds,0,status,0,0); + }).catch(function(e){ + if(e.message==="auth"){cliBusy(false);status.remove();return} + // No such endpoint: this firmware predates the console (or was built + // without it). Say so instead of retrying a route that will never exist. + if(e.status===404){cliEnd(status,"This firmware has no console endpoint — nothing was sent.");return} + if(e.status===409&&e.reqid!==reqid){cliEnd(status,"Another sequence is still running — retry shortly.");return} + if(e.status===400||e.status===413){cliEnd(status,e.message||"Rejected by the node.");return} + // Ambiguous: the request may have landed even though the reply was lost. + // Poll for it rather than reporting a failure that did not happen. + cliPoll(reqid,cmds,0,status,0,0); + }); +} +function cliEnd(status,msg){ + cliBusy(false); + status.className="err";status.textContent=msg; + $("#term-out").scrollTop=$("#term-out").scrollHeight; +} +function cliPoll(reqid,cmds,from,status,errs,idles){ + api("/api/cli/result?reqid="+encodeURIComponent(reqid)+"&from="+from,{timeout:5000}) + .then(function(r){ + if(r.state!=="idle"&&r.reqid!==reqid){cliEnd(status,"Lost track of this sequence — reload to check the node's state.");return} + if(r.state==="idle"){ + // the POST may still be in flight after a connection blip + if(idles<5){setTimeout(function(){cliPoll(reqid,cmds,from,status,errs,idles+1)},400);return} + cliEnd(status,"The node never received the command.");return; + } + (r.results||[]).forEach(function(x,i){ + // The node never echoes the command back — it may hold a password or a + // token, and we already have the sequence we sent. Match by index. + cliEcho("cmd",cmds[from+i]||""); + // `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||""); + // 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". + var reply=(x.reply||"").replace(/^>\s?/,""); + if(reply)cliEcho(ok?"rep":"err",reply); + }); + from+=(r.results||[]).length; + // "running" also covers "finished, but more results are still to be paged + // over", so keep polling until the node says done. + if(r.state!=="done"){ + if(cmds.length>1)status.textContent="running "+from+"/"+cmds.length+"…"; + setTimeout(function(){cliPoll(reqid,cmds,from,status,0,0)},250); + return; + } + status.remove(); + cliBusy(false); + cliAfter(cmds,r); + }).catch(function(e){ + if(e.message==="auth"){cliBusy(false);status.remove();return} + if(e.status===400||e.status===404){cliEnd(status,"The node no longer has a result for this sequence.");return} + if(errs<20){setTimeout(function(){cliPoll(reqid,cmds,from,status,errs+1,idles)},700);return} + cliEnd(status,"Lost the connection while the sequence was running — reload to check the node's state."); + }); +} +// A CLI `set` writes the same prefs the forms edit, so re-read them or the +// other tabs keep showing stale values. +function cliAfter(cmds,r){ + var touched=false; + cmds.forEach(function(c){ + if(/^password\s/.test(c))cli.pwd=true; + if(/^(set|password)\s/.test(c))touched=true; + }); + // Same prefix dispatch as the risk list: `poweroff now` powers off too. + if(touched)loadConfigSoft(); + // A `reboot` in the sequence is not run by the CLI — Board::reboot() never + // returns, so the node answers it and schedules the restart for after this + // read. It withholds it when any command failed, exactly as a config save + // does; say which happened rather than leaving the operator to guess. + if(r&&r.reboot_withheld){ + cliEcho("err","Not rebooting — a setting was rejected. Fix it and run \"reboot\" again."); + return; + } + if(r&&r.reboot){ + showReboot("The node is restarting. This page will try to reconnect automatically.",true); + return; + } + // clkreboot / poweroff / erase-and-flash take the node down themselves, so + // there is no result to wait for. + cmds.forEach(function(c){ + if(/^(poweroff|shutdown)/.test(c))showReboot("The node is powering off. Reconnect power to bring it back.",false,"Powering off…"); + else if(/^clkreboot/.test(c))showReboot("The node is restarting. This page will try to reconnect automatically.",true); + }); +} + +/* ---------- CLI: fit ---------- + dvh/vh both ignore the on-screen keyboard, so on a phone the prompt can end + up underneath it. visualViewport is the only thing that reports the space + actually left, so size the terminal from that while the CLI tab is open. */ +function cliFit(){ + var vv=window.visualViewport,t=$("#term"); + if(!t||$("#t-cli").classList.contains("hide")||$("#v-app").classList.contains("hide"))return; + t.style.height=""; // back to the stylesheet's height + if(!vv)return; + var base=t.offsetHeight; + // everything still owed below the terminal — the shortcut row, the card's + // padding and margin, main's padding — plus the save bar when it is up + var extra=$("main").getBoundingClientRect().bottom-t.getBoundingClientRect().bottom+6; + var sb=$("#savebar");if(sb.classList.contains("show"))extra+=sb.offsetHeight; + var top=t.getBoundingClientRect().top-vv.offsetTop; + var avail=Math.round(vv.height-top-extra); + // Only ever shrink. Growing to fill the viewport would lengthen the page, + // which lets it scroll, which frees more room — a loop that never settles. + if(avail