From d148d0c62aec3d84aea6f848ce619e6f0bed9878 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 7 Aug 2026 22:20:18 -0700 Subject: [PATCH 01/16] feat(webconfig): add a terminal CLI tab to the portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design prototype, driven entirely by the mock backend — nothing here runs on-device yet. The portal's form batch is deliberately allowlisted (WebConfigKeys.h), which leaves everything the serial console can do unreachable from a browser. This adds a fifth tab holding a real terminal: monospace white-on-black in either colour scheme, autocomplete over the full ~270-command surface, in-session history, and a confirmation step for pasted command sequences. Autocomplete goes past the flasher's : rows carry descriptions, Tab extends to the longest shared prefix before committing to a match, and once `set ` is complete it switches to completing the VALUE — enums from the command table, broker presets from /api/presets, packet-type names per CSV segment. The table is generated from a key list rather than written out per slot, so mqttN.* tracks active_slots instead of being duplicated six times. Pasting several lines never mangles the prompt: the lines are parsed (comments, blank lines and pasted `>` prompts stripped), listed back numbered, and run only after an explicit confirm. Commands that restart, erase, reflash or move the node off its network get the same confirmation singly. History is memory-only — `set wifi.pwd` and `password` pass through it. /api/cli mirrors the config-save contract (202 + reqid, poll for results) for the same reason: commands run on the node's main loop, not in the request. The one difference is that results stream, so a long sequence fills the window as it executes rather than landing all at once. The tab is hidden in setup mode, where the portal authenticates by proximity and no admin password exists yet. --- scripts/webconfig_mock_server.py | 229 ++++++++++ webui/index.html | 712 ++++++++++++++++++++++++++++++- 2 files changed, 940 insertions(+), 1 deletion(-) diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index 86885ac4..cd63cf93 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. @@ -122,6 +127,7 @@ 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.scan_started = None # ---- auth ------------------------------------------------------------- @@ -200,6 +206,15 @@ def apply_set(cfg, key, val): ADMIN_PASSWORD = val 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(",") @@ -340,6 +355,134 @@ def is_secret_key(key): return key == "wifi.pwd" 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. +# --------------------------------------------------------------------------- +CLI_MAX_CMDS = 64 # per POSTed sequence +CLI_CMD_SECS = 0.25 # simulated per-command execution time + +# 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)), +} + + +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): + if key in GETTERS: + return True, GETTERS[key](cfg) + 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, "Error: unknown config key '%s'" % key + 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": + return True, "v1.7.1-mock (observer)" + 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, "OK - filesystem erased, rebooting" + 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") + 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?" + if cli_read_key(cfg, key) is None and not re.match(r"^mqtt[1-6]\.", key): + return False, "Error: unknown config key '%s'" % key + return apply_set(cfg, key, val.strip()) + return False, "Error: unknown command '%s'" % cmd[:40] + + def valid_reqid(reqid): return isinstance(reqid, str) and bool(re.fullmatch(r"[0-9A-Fa-f]{16}", reqid)) @@ -393,6 +536,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 +562,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 @@ -526,6 +677,84 @@ 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 %d)" % 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", "cmd": c[:32]}) + + 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": [], + "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"])] + ok, reply = run_cli(ST.cfg, cmd) + job["results"].append({"cmd": cmd, "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 + return self._json(200, { + "state": j["state"], "reqid": reqid, "total": len(j["cmds"]), + "from": frm, "results": j["results"][frm:], + }) + def _scan(self): rescan = "rescan=1" in self.path now = time.time() diff --git a/webui/index.html b/webui/index.html index c634d78f..6d7e0fc2 100644 --- a/webui/index.html +++ b/webui/index.html @@ -118,6 +118,85 @@ 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 b{font-weight:500;color:var(--tgrn);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.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)} +.term-out .rep{color:#a9b7c6} +.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} @@ -249,6 +328,7 @@ canvas{width:100%;height:56px;display:block} +
@@ -410,6 +490,31 @@ canvas{width:100%;height:56px;display:block}
No data yet.
+ +
+
+
+
+ meshcore + + +
+
+
+
+ > + + +
+
+
+ Tab complete history + Esc dismisspaste multiple lines to run a sequence +
+
+
@@ -961,13 +1066,20 @@ function loadConfigSoft(){ // re-sync accepted values without clobbering chips/r /* ---------- tabs / app ---------- */ function enterApp(){ show("#v-app"); + // Setup mode authenticates by proximity — there is no admin password yet. The + // form batch is allowlisted (WebConfigKeys.h) so that is safe there, but the + // CLI is the entire command surface, `erase` and `set prv.key` included, so + // it stays behind a real login. + $('#tabs button[data-t="cli"]').classList.toggle("hide",st.mode==="setup"); loadConfig().catch(function(e){if(e.message!=="auth")toast("Failed to load config")}); } $("#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 +1397,604 @@ 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. */ +var CLI_MAX=64; // commands per submitted sequence +// [command, description] +var CLI_VERBS=[ + ["ver","Firmware version"], + ["board","Board and hardware info"], + ["clock","Show the device clock (UTC)"], + ["clock sync","Set the device clock from this browser"], + ["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}"], + ["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"], + ["log","Show the packet log summary"], + ["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","Raise the manual firmware-upload AP"], + ["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"], + ["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], + ["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}; + +// 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 · "+($("#h-sub").textContent||"")); + 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. */ +var CLI_RISK=[ + [/^erase$/,"erases the filesystem — stored settings and this node's identity"], + [/^(reboot|clkreboot)$/,"restarts the node"], + [/^(poweroff|shutdown)$/,"powers the node off"], + [/^ota update$/,"downloads and flashes new firmware, then reboots"], + [/^start ota$/,"drops this portal and raises the firmware-upload AP"], + [/^stop webconfig$/,"stops this portal"], + [/^set wifi\.(ssid|pwd)\b/,"changes WiFi — this page will drop"], + [/^set radio\b/,"changes radio parameters — a wrong value takes this node off the air"], + [/^set freq\b/,"changes the frequency — a wrong value takes this node off the air"], + [/^password\b/,"changes the admin password"], + [/^set prv\.key\b/,"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); + } + 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} + 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){ + cliEcho("cmd",x.cmd); + // `ok` is advisory; the node's own OK/Error convention is authoritative + var ok=(x.ok!=null)?x.ok:!/^\s*(err|error)\b/i.test(x.reply||""); + if(x.reply)cliEcho(ok?"rep":"err",x.reply); + }); + from+=(r.results||[]).length; + 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); + }).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){ + var touched=false,restart=null; + cmds.forEach(function(c){ + if(/^(set|password)\s/.test(c))touched=true; + if(/^(reboot|clkreboot)$/.test(c))restart=restart||"reboot"; + if(/^erase$/.test(c))restart=restart||"erase"; + if(/^(poweroff|shutdown)$/.test(c))restart="off"; + }); + if(touched)loadConfigSoft(); + if(restart==="off")showReboot("The node is powering off. Reconnect power to bring it back.",false,"Powering off…"); + else if(restart)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"))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 From 8cbc5520c90de354c88ac25c504b14f1f7625bd9 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 7 Aug 2026 22:35:00 -0700 Subject: [PATCH 02/16] build(webconfig): strip comments before embedding the portal page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator gzipped webui/index.html verbatim, so the page's comments — and this file is commented heavily by house style — were paying flash rent. A line-based pass now drops comments, indentation and blank lines before compressing. The source stays as readable as it was. Conservative on purpose: only a comment that starts its own line is removed, so a `//` inside a URL or a `/*` inside a regex can never be mistaken for one. Line breaks survive, which leaves JS statement boundaries (and the space a newline contributes between HTML inline elements) exactly as written. This ships to thousands of devices, so it is not taken on trust: - check_stripped() fails the build if the page's structure changed or the output shrank implausibly - the pass lives in its own module, shared with the mock backend's new --minify flag, so the bytes exercised in a browser are the bytes that get embedded rather than a second implementation that could drift - webconfig_minify.py joins the generator in the freshness hash, so editing the stripper forces a regenerate Today's page: 22,678 -> 17,671 bytes gzipped. --- scripts/generate_webconfig_html.py | 57 +++++++++++++++---- scripts/webconfig_minify.py | 88 ++++++++++++++++++++++++++++++ scripts/webconfig_mock_server.py | 22 +++++++- 3 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 scripts/webconfig_minify.py 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_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 cd63cf93..e1b73d2e 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -33,6 +33,7 @@ import json import os import re import secrets +import sys import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -41,6 +42,13 @@ 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 + SENTINEL = "********" ADMIN_PASSWORD = "password" # matches the default ADMIN_PASSWORD build flag BATCH_PENDING_SECS = 0.8 # how long POST->done takes, to exercise polling @@ -582,6 +590,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))) @@ -790,17 +804,19 @@ class Handler(BaseHTTPRequestHandler): def main(): - global ST, PORT + global ST, PORT, MINIFY 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("--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 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() From d90f657c73230cfecb74f72b5b37128d36afe1fd Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 7 Aug 2026 22:35:11 -0700 Subject: [PATCH 03/16] feat(webconfig): offer the console as a way out of guided setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators who already have a prepared config want to paste it, not tap through four wizard steps. A quiet `>_` chip in the setup-mode header drops straight into the terminal; everyone else still sees only the wizard. That makes the CLI reachable in setup mode, which the previous commit had deliberately blocked. Setup mode authenticates by proximity to the AP rather than by password — but the wizard already sets the admin password and rewrites the node's radio config from there, so the trust boundary is the AP either way, and refusing the console would only push these operators back to serial. Onboarding by paste does skip the one thing the wizard makes mandatory: the admin password, which /api/config enforces before it will arm a reboot. Nothing in CommonCLI enforces it, so the terminal says so on entry, and confirming a reboot without a `password` command having run warns again. That is a client- side reminder, not a gate; wiring the real gate belongs with /api/cli on-device. The console is a one-way door out of the wizard otherwise, so its header grows a "← setup" button that goes back. --- webui/index.html | 59 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/webui/index.html b/webui/index.html index 6d7e0fc2..a04dc9f3 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} @@ -143,6 +147,9 @@ body.tab-cli{padding-bottom:0} .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} +/* narrow phone: the buttons crowd the host label off. `help` is also a command + (and the banner says so), so it is the one that gives way. */ +@media(max-width:430px){.term-hd .opt{display:none}} .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} @@ -210,6 +217,12 @@ body.tab-cli{padding-bottom:0}
connecting…
+ +
@@ -496,7 +509,9 @@ body.tab-cli{padding-bottom:0}
meshcore - + +
@@ -612,6 +627,9 @@ function boot(){ $("#h-sub").textContent=s.role+" · "+s.fw+" · "+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||[]; @@ -623,7 +641,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 ---------- */ @@ -1066,13 +1086,17 @@ function loadConfigSoft(){ // re-sync accepted values without clobbering chips/r /* ---------- tabs / app ---------- */ function enterApp(){ show("#v-app"); - // Setup mode authenticates by proximity — there is no admin password yet. The - // form batch is allowlisted (WebConfigKeys.h) so that is safe there, but the - // CLI is the entire command surface, `erase` and `set prv.key` included, so - // it stays behind a real login. - $('#tabs button[data-t="cli"]').classList.toggle("hide",st.mode==="setup"); 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)}); @@ -1542,7 +1566,7 @@ var CLI_SLOT=[ ]; 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}; +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. @@ -1596,6 +1620,13 @@ function cliGap(){var d=cliEcho("gap","");return d} function cliClear(){$("#term-out").innerHTML="";cliBanner();$("#term-in").focus()} function cliBanner(){ cliEcho("sys","MeshCore · "+($("#h-sub").textContent||"")); + 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(){ @@ -1855,6 +1886,14 @@ function cliConfirm(cmds){ 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"; + p.textContent="⚠ No password command has run here. Set the admin password before rebooting, "+ + "or the node keeps 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"); @@ -1949,6 +1988,7 @@ function cliPoll(reqid,cmds,from,status,errs,idles){ function cliAfter(cmds){ var touched=false,restart=null; cmds.forEach(function(c){ + if(/^password\s/.test(c))cli.pwd=true; if(/^(set|password)\s/.test(c))touched=true; if(/^(reboot|clkreboot)$/.test(c))restart=restart||"reboot"; if(/^erase$/.test(c))restart=restart||"erase"; @@ -1965,7 +2005,7 @@ function cliAfter(cmds){ 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"))return; + 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; @@ -1987,6 +2027,7 @@ if(window.visualViewport){ } function enterCli(){ $("#term-host").textContent="meshcore@"+($("#h-name").textContent||"node"); + $("#term-setup").classList.toggle("hide",st.mode!=="setup"); if(!cli.shown){cli.shown=true;cliBanner()} window.scrollTo(0,0); // the terminal is the whole tab; show all of it cliFit(); From 105d71478d45bdd2142213ae69cc6ef5ea1e9b99 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 7 Aug 2026 22:39:02 -0700 Subject: [PATCH 04/16] refactor(webconfig): drop the fake shell prompt from the terminal header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "meshcore@" borrowed the user@host convention without the referents: there is no user concept here, and the node name is already in the page header directly above, larger. It was decoration duplicating what was on screen — and it crowded the header enough that `help` had to be hidden below 430px. Removing it fits all three buttons on a 375px phone, so that media query goes with it. --- webui/index.html | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/webui/index.html b/webui/index.html index a04dc9f3..9ab44aa8 100644 --- a/webui/index.html +++ b/webui/index.html @@ -142,14 +142,10 @@ canvas{width:100%;height:56px;display:block} 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 b{font-weight:500;color:var(--tgrn);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .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} -/* narrow phone: the buttons crowd the host label off. `help` is also a command - (and the banner says so), so it is the one that gives way. */ -@media(max-width:430px){.term-hd .opt{display:none}} .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} @@ -508,10 +504,10 @@ body.tab-cli{padding-bottom:0}
- meshcore + - +
@@ -2026,7 +2022,6 @@ if(window.visualViewport){ window.visualViewport.addEventListener("scroll",cliFit); } function enterCli(){ - $("#term-host").textContent="meshcore@"+($("#h-name").textContent||"node"); $("#term-setup").classList.toggle("hide",st.mode!=="setup"); if(!cli.shown){cli.shown=true;cliBanner()} window.scrollTo(0,0); // the terminal is the whole tab; show all of it From b72b02f55b589b313cbb7b883db46224ef060492 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 7 Aug 2026 22:52:15 -0700 Subject: [PATCH 05/16] fix(webconfig): make the mock answer the whole CLI surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get radio.fem.rxgain` returned "unknown config key" from the mock, which reads as the terminal offering a command that does not exist. It does exist: CommonCLI implements get and set for it, gated at runtime by Board::canControlLoRaFemLna() rather than compiled out, so the command is present in every build and the board answers for itself — "Error: unsupported" where there is no front-end module. Auditing the whole table found 31 of 70 config keys unanswered, all the ones no portal form drives: alert.*, bridge.*, owner.info, path.hash.mode, dutycycle and the rest. Plus 14 verbs (gps, powersaving, sensor, region, clock sync) with no handler at all. They now live in a "cli" section of the mock config, typed through the existing lookup tables and stripped from /api/config, which does not carry them. Two real bugs behind that: - the `set` path gated on whether a key was *readable*, so write-only and computed keys (prv.key, dutycycle, radio.fem.rxgain) were rejected as unknown. apply_set now owns that decision alone. - apply_set accepted anything it did not recognise and replied OK. That leniency is what let the gap hide: a CLI `set` on an unknown key looked like it worked. It is strict now — verified against every key in WC_ALLOWED_SET_KEYS so the form batch is unaffected. Also mqtt.neighbors / mqtt.neighbors.interval, which the MQTT tab binds but the mock's config never carried, so that toggle could not round-trip. webconfig_cli_audit.py keeps the two honest: it drives every command the autocomplete table offers through /api/cli and fails on anything unanswered. 119 commands, all answered. --- scripts/webconfig_cli_audit.py | 157 +++++++++++++++++++++++++++++++ scripts/webconfig_mock_server.py | 122 ++++++++++++++++++++++-- 2 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 scripts/webconfig_cli_audit.py diff --git a/scripts/webconfig_cli_audit.py b/scripts/webconfig_cli_audit.py new file mode 100644 index 00000000..ca88ff6d --- /dev/null +++ b/scripts/webconfig_cli_audit.py @@ -0,0 +1,157 @@ +#!/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 + + +# set -> get pairs, checking a value survives the round trip. +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"), + ("set guest.password hunter2", "get guest.password", "********"), +] + + +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] + + 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, chunk=40): + out = [] + for i in range(0, len(cmds), chunk): + out += self._sequence(cmds[i:i + chunk]) + 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) + while True: + r = json.load(self._open("/api/cli/result?reqid=" + reqid)) + if r["state"] == "done": + return r["results"] + 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 res in cli.run(cmds): + if res["ok"]: + continue + want = EXPECTED_FAILURES.get(res["cmd"]) + if want and want in res["reply"]: + continue + unexpected.append((res["cmd"], res["reply"])) + print("commands offered by autocomplete : %d" % len(cmds)) + print("answered : %d" % (len(cmds) - len(unexpected))) + for cmd, reply in unexpected: + print(" FAIL %-30s %s" % (cmd, reply)) + failures += unexpected + + 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], results[i * 2 + 1] + if setr["ok"] and getr["reply"] == want: + continue + print(" FAIL %-30s got %r, wanted %r (set: %s)" + % (getc, getr["reply"], want, setr["reply"])) + failures.append((getc, getr["reply"])) + + 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_mock_server.py b/scripts/webconfig_mock_server.py index e1b73d2e..4623f9e5 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -113,8 +113,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": "", + }, } @@ -151,6 +166,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 "" @@ -176,13 +192,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"), @@ -194,6 +212,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) @@ -214,6 +242,19 @@ 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) @@ -243,6 +284,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"] = "" @@ -282,7 +329,10 @@ 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`. + return False, "Error: unknown config key '%s'" % key # Payload-type names accepted alongside the decimal form. Mirrors @@ -360,7 +410,9 @@ 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)) # --------------------------------------------------------------------------- @@ -389,6 +441,18 @@ GETTERS = { "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", + # 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, } @@ -405,7 +469,8 @@ def cli_mqtt_status(cfg): def cli_get(cfg, key): if key in GETTERS: - return True, GETTERS[key](cfg) + 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. @@ -464,6 +529,50 @@ def run_cli(cfg, line): if cmd == "neighbors": return True, ("d4e5f60718 -71 dBm snr 9.5 2m ago\n" "1122334455 -94 dBm snr 2.0 14m ago") + if cmd == "clock sync": + return True, "OK - clock set: %s UTC" % time.strftime("%H:%M - %d/%m/%Y", time.gmtime()) + 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-"): @@ -485,8 +594,9 @@ def run_cli(cfg, line): key, _, val = rest.partition(" ") if not key: return False, "Error: set what?" - if cli_read_key(cfg, key) is None and not re.match(r"^mqtt[1-6]\.", key): - return False, "Error: unknown config key '%s'" % key + # 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, "Error: unknown command '%s'" % cmd[:40] From 33d8766d480471bec0ce0d55e1d37bebf42c026b Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 7 Aug 2026 23:01:52 -0700 Subject: [PATCH 06/16] fix(webconfig): report a missing endpoint honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare 404 carries no body, so r.json() rejected and the parse failure escaped with no HTTP status attached. Every caller then had to treat "this route does not exist" as an ambiguous network failure — for the CLI that meant ~14 seconds of polling before reporting a lost connection, which is the wrong diagnosis and the wrong wait. api() now substitutes an empty object when an *error* response has no readable JSON, so the status survives onto the error. Successful responses must still parse, or a captive portal's HTML would sail through as valid config. The CLI names the case outright: firmware without /api/cli says so in 100ms instead of retrying a route that will never exist. --- webui/index.html | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/webui/index.html b/webui/index.html index 9ab44aa8..66e9a9f2 100644 --- a/webui/index.html +++ b/webui/index.html @@ -592,7 +592,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. @@ -1936,6 +1943,9 @@ function cliRun(cmds){ 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. From d7109c185caab4011c637719a025c6437fb7b13a Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 7 Aug 2026 23:16:38 -0700 Subject: [PATCH 07/16] feat(webconfig): implement /api/cli on the device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal has been driving the mock since it was built. This is the firmware side, so it works on hardware. Same 202 + reqid + poll contract as a config save, for the same reason: CommonCLI touches prefs, the radio and the filesystem, none of which may be reached from the async_tcp task. Commands go into the deferred slot and tick() drains them on the loop task. Unlike a save this is not allowlisted — reaching what the serial console reaches is the point, and execCommand() already passes sender_timestamp 0, so the terminal gets exactly the serial console's privilege. Authentication is the boundary, as it is there. The CLI shares the config batch's slot 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. Each reader checks the kind, so neither can serve the other's results. Three things the mock could not have taught us: - Board::reboot() does not return, so a drained `reboot` would take the node down before the client read a single result. It is answered rather than executed, and the batch arms the existing deferred-reboot path once the results have been read — withheld if any command failed, exactly as a save withholds one. clkreboot/poweroff/ota update do real work on the way down and cannot be faked, so they still drop the connection; the UI warns first. - `password ` echoes the new password in its reply. The config path already scrubbed that by key; a CLI entry has no key, so it is matched on the command. CLI commands are also kept out of the serial log entirely — the browser session and the serial console are different audiences. - MAX_BATCH is 24, not the 64 the page assumed. It is reported as status.max_cmds instead of hardcoded, so the cap cannot drift. Results stream and page (kCliResultPage = 8), and "done" means the client has been handed every result, not merely that execution finished — otherwise a client that stops polling at "done" loses the last page. Commands are never echoed back: they may carry a secret, and the client matches by index. New decisions live in WebConfigBatch.h with the rest, covered by three host tests. Builds clean for heltec_v4_repeater_observer_mqtt; 22 batch + 14 keys tests pass; the CLI audit reports 119/119 against the updated mock. --- scripts/webconfig_cli_audit.py | 32 ++- scripts/webconfig_mock_server.py | 44 ++- src/helpers/WebConfigBatch.h | 42 +++ src/helpers/esp32/WebConfigServer.cpp | 269 +++++++++++++++++- src/helpers/esp32/WebConfigServer.h | 12 +- .../test_webconfig_batch.cpp | 41 +++ webui/index.html | 43 ++- 7 files changed, 446 insertions(+), 37 deletions(-) diff --git a/scripts/webconfig_cli_audit.py b/scripts/webconfig_cli_audit.py index ca88ff6d..1bca892f 100644 --- a/scripts/webconfig_cli_audit.py +++ b/scripts/webconfig_cli_audit.py @@ -82,6 +82,9 @@ class Client: 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"} @@ -91,10 +94,16 @@ class Client: self.base + path, data=data, headers=headers, method="POST" if data is not None else "GET")) - def run(self, cmds, chunk=40): + 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), chunk): - out += self._sequence(cmds[i:i + chunk]) + 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): @@ -108,10 +117,14 @@ class Client: 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=" + reqid)) + r = json.load(self._open("/api/cli/result?reqid=%s&from=%d" % (reqid, len(out)))) + out += r.get("results", []) if r["state"] == "done": - return r["results"] + return out time.sleep(0.05) @@ -126,15 +139,16 @@ def main(): cmds = table() unexpected = [] - for res in cli.run(cmds): + for cmd, res in cli.run(cmds): if res["ok"]: continue - want = EXPECTED_FAILURES.get(res["cmd"]) + want = EXPECTED_FAILURES.get(cmd) if want and want in res["reply"]: continue - unexpected.append((res["cmd"], res["reply"])) + 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 @@ -142,7 +156,7 @@ def main(): 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], results[i * 2 + 1] + setr, getr = results[i * 2][1], results[i * 2 + 1][1] if setr["ok"] and getr["reply"] == want: continue print(" FAIL %-30s got %r, wanted %r (set: %s)" diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index 4623f9e5..103204bd 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -182,6 +182,7 @@ class State: "fw": "v1.7.1-mock", "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, } @@ -424,8 +425,14 @@ def is_secret_key(key): # 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. # --------------------------------------------------------------------------- -CLI_MAX_CMDS = 64 # per POSTed sequence +# 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" # Commands the device answers but that have no config-key equivalent. GETTERS = { @@ -821,14 +828,14 @@ class Handler(BaseHTTPRequestHandler): 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 %d)" % 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", "cmd": c[:32]}) + return self._json(400, {"error": "command too long"}) with ST.lock: self._cli_advance(ST.cli) @@ -838,6 +845,7 @@ class Handler(BaseHTTPRequestHandler): 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": CLI_DEFERRED_REBOOT in cmds, "next_at": time.time() + CLI_CMD_SECS} return self._json(202, {"state": "running", "reqid": reqid, "total": len(cmds)}) @@ -850,8 +858,16 @@ class Handler(BaseHTTPRequestHandler): while (job.get("state") == "running" and len(job["results"]) < len(job["cmds"]) and now >= job["next_at"]): cmd = job["cmds"][len(job["results"])] - ok, reply = run_cli(ST.cfg, cmd) - job["results"].append({"cmd": cmd, "ok": ok, "reply": reply}) + if cmd == CLI_DEFERRED_REBOOT: + ok, reply = True, "OK - reboot queued" + else: + ok, reply = run_cli(ST.cfg, cmd) + if cmd.startswith("password "): + reply = "OK" # never echo the new password back + job["all_ok"] = job.get("all_ok", True) and 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 @@ -874,10 +890,20 @@ class Handler(BaseHTTPRequestHandler): if j.get("reqid") != reqid: return self._json(404, {"error": "unknown request"}) self._cli_advance(j) # one command per CLI_CMD_SECS - return self._json(200, { - "state": j["state"], "reqid": reqid, "total": len(j["cmds"]), - "from": frm, "results": j["results"][frm:], - }) + # 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 diff --git a/src/helpers/WebConfigBatch.h b/src/helpers/WebConfigBatch.h index 2f93f56d..c69562e2 100644 --- a/src/helpers/WebConfigBatch.h +++ b/src/helpers/WebConfigBatch.h @@ -168,6 +168,48 @@ 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; +} + // -------------------------------------------------------------------------- // Reboot fire (.cpp:262-265) and isRebootPending (.cpp:70-74). // -------------------------------------------------------------------------- diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index b9108001..92ab5e89 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -28,6 +28,22 @@ 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. +static inline bool wcIsDeferredReboot(const char* cmd) { + return strcmp(cmd, "reboot") == 0; +} +// 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; +} + // 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); @@ -315,20 +331,41 @@ 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"); + // Config entries carry the key; a CLI entry is matched on the command. + if (wcIsAdminPasswordKey(e.key) || wcCliEchoesSecret(e.cmd)) 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); } _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 +455,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); }); @@ -495,6 +536,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 +826,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 +866,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 +928,212 @@ 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; + 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; + e.key[0] = 0; // CLI entries have no config key + if (wcIsDeferredReboot(e.cmd)) defer_reboot = true; + count++; + } + if (count == 0) { + req->send(400, "application/json", "{\"error\":\"no commands\"}"); + 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"] = strncmp(_batch[i].reply, "OK", 2) == 0; + 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..7ef23a92 100644 --- a/src/helpers/esp32/WebConfigServer.h +++ b/src/helpers/esp32/WebConfigServer.h @@ -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; @@ -141,6 +146,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 +205,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..2b6deb82 100644 --- a/test/test_webconfig_batch/test_webconfig_batch.cpp +++ b/test/test_webconfig_batch/test_webconfig_batch.cpp @@ -190,6 +190,47 @@ 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)); +} + +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/webui/index.html b/webui/index.html index 66e9a9f2..6d8e87ae 100644 --- a/webui/index.html +++ b/webui/index.html @@ -626,6 +626,7 @@ 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; $("#h-name").textContent=s.name||"MeshCore"; $("#h-sub").textContent=s.role+" · "+s.fw+" · "+s.board; var b=$("#h-badge");b.classList.remove("hide"); @@ -1428,7 +1429,10 @@ function showReboot(msg,reconnect,title){ 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. */ -var CLI_MAX=64; // commands per submitted sequence +// 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"], @@ -1967,13 +1971,17 @@ function cliPoll(reqid,cmds,from,status,errs,idles){ 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){ - cliEcho("cmd",x.cmd); + (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 OK/Error convention is authoritative var ok=(x.ok!=null)?x.ok:!/^\s*(err|error)\b/i.test(x.reply||""); if(x.reply)cliEcho(ok?"rep":"err",x.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); @@ -1981,7 +1989,7 @@ function cliPoll(reqid,cmds,from,status,errs,idles){ } status.remove(); cliBusy(false); - cliAfter(cmds); + 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} @@ -1991,18 +1999,31 @@ function cliPoll(reqid,cmds,from,status,errs,idles){ } // 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){ - var touched=false,restart=null; +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; - if(/^(reboot|clkreboot)$/.test(c))restart=restart||"reboot"; - if(/^erase$/.test(c))restart=restart||"erase"; - if(/^(poweroff|shutdown)$/.test(c))restart="off"; }); if(touched)loadConfigSoft(); - if(restart==="off")showReboot("The node is powering off. Reconnect power to bring it back.",false,"Powering off…"); - else if(restart)showReboot("The node is restarting. This page will try to reconnect automatically.",true); + // 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 — some commands failed. Fix them 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 ---------- From d532e4ea86d5cfd51b37bc08c04eadeeb2080d1a Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 8 Aug 2026 08:14:27 -0700 Subject: [PATCH 08/16] fix(webconfig): correct reply classification and the missing MyMesh commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things hardware turned up. The whole terminal came back red. The endpoint decided a command had succeeded by testing its reply for an "OK" prefix — the convention the config batch relies on, and a safe one there because every allowlisted setter uses it. The CLI reaches the whole surface, where success has no single shape: setters answer "OK...", getters answer "> value", `erase` answers "File system erase: OK". Only failure is uniform ("Err", "ERR:", "Error:"), so that is what the CLI now tests for. Colour was the visible half. The other half was worse: _batch_all_ok went false the moment a sequence contained a `get`, so a script ending in `reboot` was told some commands had failed and the reboot was withheld. Replies are green now and red means the node said no, which is what red should have meant all along. The "> " a getter prefixes its value with is dropped for display — on the serial console it sets the value apart, here it collides with the prompt glyph that means "you typed this". The mock emits that marker too; had it done so from the start, this would have shown up before the flash. Second: discover.neighbors and discover.scopes did not autocomplete, because MyMesh::handleCommand intercepts a few commands before delegating to CommonCLI and the table was built by reading CommonCLI alone. setperm, `get acl` and `shutdown` were missing for the same reason. The audit could not have caught that: it drove every command the table offered and checked the mock answered, which only finds gaps in one direction. It now also reads the command literals the firmware dispatches on — across CommonCLI, CommonCLI_Observer and MyMesh — and fails on any the table does not offer. That check found `shutdown` immediately. 122 commands, all answered, none missing. 22 batch + 14 keys tests pass. --- scripts/webconfig_cli_audit.py | 47 ++++++++++++++++++++++++--- scripts/webconfig_mock_server.py | 23 +++++++++++++ src/helpers/esp32/WebConfigServer.cpp | 21 +++++++++--- webui/index.html | 22 ++++++++++--- 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/scripts/webconfig_cli_audit.py b/scripts/webconfig_cli_audit.py index 1bca892f..4868c705 100644 --- a/scripts/webconfig_cli_audit.py +++ b/scripts/webconfig_cli_audit.py @@ -64,7 +64,33 @@ def table(): return gets + plain -# set -> get pairs, checking a value survives the round trip. +# 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. +NOT_OFFERED = { + "tls.bundletest", # TLS debugging, not an operator command +} + + +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"), @@ -153,15 +179,28 @@ def main(): 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] - if setr["ok"] and getr["reply"] == want: + # `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, getr["reply"], want, setr["reply"])) - failures.append((getc, getr["reply"])) + % (getc, got, want, setr["reply"])) + failures.append((getc, got)) print("\n%s" % ("FAILED: %d" % len(failures) if failures else "all clear")) return 1 if failures else 0 diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index 103204bd..e85852c9 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -449,6 +449,7 @@ GETTERS = { "%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: ( @@ -475,6 +476,18 @@ def cli_mqtt_status(cfg): 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") @@ -536,6 +549,16 @@ def run_cli(cfg, line): 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 == "clock sync": return True, "OK - clock set: %s UTC" % time.strftime("%H:%M - %d/%m/%Y", time.gmtime()) if cmd == "region": diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 92ab5e89..3eb0fb4e 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -43,6 +43,16 @@ static inline bool wcIsDeferredReboot(const char* cmd) { static inline bool wcCliEchoesSecret(const char* cmd) { return strncmp(cmd, "password ", 9) == 0; } +// Success has no single shape across the CLI: setters answer "OK...", getters +// answer "> value", and a few answer free-form ("File system erase: OK"). Only +// FAILURE is uniform — an "Err"/"ERR:"/"Error:" prefix. So the CLI must test for +// the failure prefix, where the config batch can test for "OK" because every +// allowlisted setter uses it. Testing for "OK" here marked every `get` a +// failure, which turned the terminal red and withheld requested reboots. +static inline bool wcCliReplyIsOk(const char* r) { + return !((r[0] == 'E' || r[0] == 'e') && (r[1] == 'R' || r[1] == 'r') && + (r[2] == 'R' || r[2] == 'r')); +} // 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) { @@ -349,10 +359,11 @@ void WebConfigServer::drainBatch(uint32_t now) { // Overwrite it: the command cannot fail, so there is nothing to report. // Config entries carry the key; a CLI entry is matched on the command. if (wcIsAdminPasswordKey(e.key) || wcCliEchoesSecret(e.cmd)) 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); + // A config batch is all allowlisted setters, which uniformly answer "OK"; + // the CLI reaches the whole surface, where only failure has a fixed shape. + _batch_all_ok = WebConfigBatch::nextAllOk( + _batch_all_ok, _batch_kind == BATCH_CLI ? wcCliReplyIsOk(e.reply) + : strncmp(e.reply, "OK", 2) == 0); } _batch_last_cmd = millis(); // Config entries are named by their (non-secret) key. A CLI command is @@ -1114,7 +1125,7 @@ void WebConfigServer::handleCliResult(AsyncWebServerRequest* req) { 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"] = strncmp(_batch[i].reply, "OK", 2) == 0; + r["ok"] = wcCliReplyIsOk(_batch[i].reply); r["reply"] = (const char*)_batch[i].reply; } if (final_read) { diff --git a/webui/index.html b/webui/index.html index 6d8e87ae..7d4a1107 100644 --- a/webui/index.html +++ b/webui/index.html @@ -150,7 +150,9 @@ body.tab-cli{padding-bottom:0} .term-out>div{white-space:pre-wrap;word-break:break-word} .term-out .cmd{color:#fff} .term-out .cmd:before{content:"> ";color:var(--tgrn)} -.term-out .rep{color:#a9b7c6} +/* 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} @@ -1444,6 +1446,11 @@ var CLI_VERBS=[ ["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}"], @@ -1480,6 +1487,7 @@ var CLI_VERBS=[ ["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] @@ -1490,6 +1498,7 @@ var CLI_KEYS=[ ["lat","Advert latitude",0], ["lon","Advert longitude",0], ["public.key","This node's public key",1], + ["acl","Access control list (per-node permissions)",1], ["prv.key","Restore an identity {64-hex-char-key}",2], ["role","Node role",1], ["radio","Radio parameters {freq,bw,sf,cr}",0], @@ -1975,9 +1984,14 @@ function cliPoll(reqid,cmds,from,status,errs,idles){ // 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 OK/Error convention is authoritative - var ok=(x.ok!=null)?x.ok:!/^\s*(err|error)\b/i.test(x.reply||""); - if(x.reply)cliEcho(ok?"rep":"err",x.reply); + // `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 From 75d4656e591ab2d1fd59df14a755e0f6ae369742 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 8 Aug 2026 08:28:18 -0700 Subject: [PATCH 09/16] chore(variants): drop the dead MAX_MQTT_BROKERS build flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing has ever read it. The slot count comes from two other places: the persisted array size (MAX_MQTT_SLOTS / RUNTIME_MQTT_SLOTS in MQTTPresets.h) and the runtime cap MQTTBridge::getMaxActiveSlots(), which answers 5 with PSRAM and 2 without. A flag reading MAX_MQTT_BROKERS=3 sitting in every observer env implies a third, lower limit that does not exist — it cost me a wrong answer about how many MQTT slots a Heltec V4 exposes. 36 lines across 15 variants, all of them =3. Removing an unread -D cannot change code: heltec_v4_repeater_observer_mqtt builds to a byte-identical size before and after (1647744). The image checksum does differ, but so does it between two clean builds of untouched source — ESP-IDF stamps the app descriptor with the build time — so size is the meaningful comparison here. All 606 envs still parse; heltec_v4, heltec_v3, station_g2, rak3112 and xiao_s3_wio observer targets all build. --- variants/heltec_t190/platformio.ini | 2 -- variants/heltec_tracker_v2/platformio.ini | 4 ---- variants/heltec_v3/platformio.ini | 4 ---- variants/heltec_v4/platformio.ini | 4 ---- variants/lilygo_t3s3/platformio.ini | 2 -- variants/lilygo_tbeam_1w/platformio.ini | 2 -- variants/lilygo_tbeam_SX1262/platformio.ini | 2 -- variants/lilygo_tbeam_SX1276/platformio.ini | 2 -- variants/lilygo_tbeam_supreme_SX1262/platformio.ini | 2 -- variants/lilygo_tlora_v2_1/platformio.ini | 2 -- variants/rak3112/platformio.ini | 2 -- variants/station_g2/platformio.ini | 2 -- variants/station_g3_esp32/platformio.ini | 2 -- variants/thinknode_m7/platformio.ini | 2 -- variants/xiao_s3_wio/platformio.ini | 2 -- 15 files changed, 36 deletions(-) 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 From c831e599ec6435eaf827d7e58a17a7f30ccd7d7d Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 8 Aug 2026 08:40:49 -0700 Subject: [PATCH 10/16] fix(webconfig): tighten CLI failure detection, reboot deferral and refusals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings from review, all confirmed against the source. Failure classification (P2). Testing replies for an "Err" prefix passed five other shapes off as success: "Unknown command", "unknown config: x", "??: x", "Can't find GPS", "(ERR: clock cannot go backwards)" and "File system erase: Err". They rendered green, and worse, left _batch_all_ok true — so a queued reboot went ahead after commands that had failed, defeating the gate entirely. Rather than lengthen one guess, the two questions are now asked separately, each erring safe: - colour asks "does this look like a failure", against every shape CommonCLI actually emits, enumerated in WebConfigBatch.h and pinned by a host test that uses the literal strings. Getting this wrong is cosmetic. - the reboot gate asks something narrower and answerable: "did every setting I asked for take". Only `set`/`password` gate it, and only on the "OK" prefix every setter keeps. Diagnostics no longer gate a reboot at all, so a harmless `memory` cannot strand one and no guess is made about "> value". Reboot deferral (P2). CommonCLI dispatches on a six-byte prefix, so `reboot now` and `rebooted` reach Board::reboot() too. Matching exactly meant those variants skipped both the confirmation and the deferral and took the node down mid-drain — the precise failure deferral exists to prevent. Both sides now anchor the way the firmware dispatches, and the UI's risk matcher with them. Three commands the portal cannot honestly serve are refused at POST with a reason, and dropped from autocomplete, instead of running and lying: - `start ota` builds a second AsyncWebServer on port 80 with no bind check and answers "Started" regardless; the portal already holds that port, so it could only leak the allocation and inhibit sleep. - `clock sync` takes its time from the caller's timestamp, which a web request has none of, so CommonCLI always rejected it. `time ` works and remains offered. - bare `log` and `get acl` write their real output to Serial and hand back a stub the terminal showed as success; `log` also streams a whole file from the loop task, stalling the mesh and radio while it does. The mock now emits the same failure shapes it used to fake as successes, so these are reproducible off-hardware. 24 batch + 14 keys tests pass; audit reports 119/119 answered, 0 missing, 4/4 refused with a reason. --- scripts/webconfig_cli_audit.py | 26 ++++++- scripts/webconfig_mock_server.py | 66 +++++++++++++--- src/helpers/WebConfigBatch.h | 63 +++++++++++++++ src/helpers/esp32/WebConfigServer.cpp | 77 +++++++++++++++---- .../test_webconfig_batch.cpp | 61 +++++++++++++++ webui/index.html | 38 +++++---- 6 files changed, 284 insertions(+), 47 deletions(-) diff --git a/scripts/webconfig_cli_audit.py b/scripts/webconfig_cli_audit.py index 4868c705..14294334 100644 --- a/scripts/webconfig_cli_audit.py +++ b/scripts/webconfig_cli_audit.py @@ -73,9 +73,15 @@ COMMAND_SOURCES = [ "examples/simple_repeater/MyMesh.cpp", ] -# Firmware commands the table deliberately does not offer. +# 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 + "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 } @@ -202,6 +208,22 @@ def main(): % (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 diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index e85852c9..5b94ce54 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -333,7 +333,8 @@ def apply_set(cfg, key, val): # 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`. - return False, "Error: unknown config key '%s'" % key + # Verbatim shape from CommonCLI::handleSetCmd's fallthrough. + return False, "unknown config: %s" % key # Payload-type names accepted alongside the decimal form. Mirrors @@ -432,7 +433,38 @@ 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" +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_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 = { @@ -497,7 +529,7 @@ def _cli_get_value(cfg, key): return True, SENTINEL if cli_read_key(cfg, key) else "(not set)" val = cli_read_key(cfg, key) if val is None: - return False, "Error: unknown config key '%s'" % key + return False, "??: %s" % key # CommonCLI::handleGetCmd fallthrough return True, str(val) @@ -542,7 +574,7 @@ def run_cli(cfg, line): if cmd in ("poweroff", "shutdown"): return True, "OK - powering off" if cmd == "erase": - return True, "OK - filesystem erased, rebooting" + return True, "File system erase: OK" if cmd == "memory": return True, ("heap free: 142000\nheap min: 118000\n" "largest block: 96000\npsram free: 3980000") @@ -559,8 +591,10 @@ def run_cli(cfg, line): if len(parts) != 2 or not _hex64(parts[0]): return False, "Err - bad params" return True, "OK" - if cmd == "clock sync": - return True, "OK - clock set: %s UTC" % time.strftime("%H:%M - %d/%m/%Y", time.gmtime()) + 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": @@ -628,7 +662,7 @@ def run_cli(cfg, line): # key is *readable* rejected write-only and computed ones (`dutycycle`, # `prv.key`, `radio.fem.rxgain`). return apply_set(cfg, key, val.strip()) - return False, "Error: unknown command '%s'" % cmd[:40] + return False, "Unknown command" def valid_reqid(reqid): @@ -859,6 +893,9 @@ class Handler(BaseHTTPRequestHandler): 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}) with ST.lock: self._cli_advance(ST.cli) @@ -868,7 +905,8 @@ class Handler(BaseHTTPRequestHandler): 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": CLI_DEFERRED_REBOOT in cmds, + "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)}) @@ -881,13 +919,17 @@ class Handler(BaseHTTPRequestHandler): 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 == CLI_DEFERRED_REBOOT: - ok, reply = True, "OK - reboot queued" + if cmd.startswith(CLI_DEFERRED_REBOOT): + reply = "OK - reboot queued" else: - ok, reply = run_cli(ST.cfg, cmd) + _, reply = run_cli(ST.cfg, cmd) if cmd.startswith("password "): reply = "OK" # never echo the new password back - job["all_ok"] = job.get("all_ok", True) and ok + 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}) diff --git a/src/helpers/WebConfigBatch.h b/src/helpers/WebConfigBatch.h index c69562e2..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 / @@ -210,6 +211,68 @@ 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/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 3eb0fb4e..903036cd 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -34,8 +34,44 @@ static inline bool isSecretKey(const char* key) { return wcIsSecretKey(key); } // 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 strcmp(cmd, "reboot") == 0; + 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 @@ -43,16 +79,9 @@ static inline bool wcIsDeferredReboot(const char* cmd) { static inline bool wcCliEchoesSecret(const char* cmd) { return strncmp(cmd, "password ", 9) == 0; } -// Success has no single shape across the CLI: setters answer "OK...", getters -// answer "> value", and a few answer free-form ("File system erase: OK"). Only -// FAILURE is uniform — an "Err"/"ERR:"/"Error:" prefix. So the CLI must test for -// the failure prefix, where the config batch can test for "OK" because every -// allowlisted setter uses it. Testing for "OK" here marked every `get` a -// failure, which turned the terminal red and withheld requested reboots. -static inline bool wcCliReplyIsOk(const char* r) { - return !((r[0] == 'E' || r[0] == 'e') && (r[1] == 'R' || r[1] == 'r') && - (r[2] == 'R' || r[2] == 'r')); -} +// 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) { @@ -359,11 +388,14 @@ void WebConfigServer::drainBatch(uint32_t now) { // Overwrite it: the command cannot fail, so there is nothing to report. // Config entries carry the key; a CLI entry is matched on the command. if (wcIsAdminPasswordKey(e.key) || wcCliEchoesSecret(e.cmd)) strcpy(e.reply, "OK"); - // A config batch is all allowlisted setters, which uniformly answer "OK"; - // the CLI reaches the whole surface, where only failure has a fixed shape. - _batch_all_ok = WebConfigBatch::nextAllOk( - _batch_all_ok, _batch_kind == BATCH_CLI ? wcCliReplyIsOk(e.reply) - : strncmp(e.reply, "OK", 2) == 0); + // 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(); // Config entries are named by their (non-secret) key. A CLI command is @@ -1040,6 +1072,17 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) { 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; count++; @@ -1125,7 +1168,7 @@ void WebConfigServer::handleCliResult(AsyncWebServerRequest* req) { 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"] = wcCliReplyIsOk(_batch[i].reply); + r["ok"] = !WebConfigBatch::cliReplyIsFailure(_batch[i].reply); r["reply"] = (const char*)_batch[i].reply; } if (final_read) { diff --git a/test/test_webconfig_batch/test_webconfig_batch.cpp b/test/test_webconfig_batch/test_webconfig_batch.cpp index 2b6deb82..813412c5 100644 --- a/test/test_webconfig_batch/test_webconfig_batch.cpp +++ b/test/test_webconfig_batch/test_webconfig_batch.cpp @@ -221,6 +221,67 @@ TEST(WebConfigBatch, CliReadIsDoneOnlyOnceEveryResultHasBeenHandedOver) { 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 diff --git a/webui/index.html b/webui/index.html index 7d4a1107..77174b1a 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1440,7 +1440,8 @@ var CLI_VERBS=[ ["ver","Firmware version"], ["board","Board and hardware info"], ["clock","Show the device clock (UTC)"], - ["clock sync","Set the device clock from this browser"], + // `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"], @@ -1459,7 +1460,8 @@ var CLI_VERBS=[ ["stats-packets","Per-packet-type counters"], ["stats-radio","Radio counters (RSSI, SNR, noise)"], ["stats-radio-diag","Extended radio diagnostics"], - ["log","Show the packet log summary"], + // 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"], @@ -1480,7 +1482,7 @@ var CLI_VERBS=[ ["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","Raise the manual firmware-upload AP"], + // `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"], @@ -1498,7 +1500,7 @@ var CLI_KEYS=[ ["lat","Advert latitude",0], ["lon","Advert longitude",0], ["public.key","This node's public key",1], - ["acl","Access control list (per-node permissions)",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], @@ -1852,18 +1854,21 @@ function cliParse(text){ /* ---------- 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"], - [/^(reboot|clkreboot)$/,"restarts the node"], - [/^(poweroff|shutdown)$/,"powers the node off"], - [/^ota update$/,"downloads and flashes new firmware, then reboots"], - [/^start ota$/,"drops this portal and raises the firmware-upload AP"], + [/^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)\b/,"changes WiFi — this page will drop"], - [/^set radio\b/,"changes radio parameters — a wrong value takes this node off the air"], - [/^set freq\b/,"changes the frequency — a wrong value takes this node off the air"], - [/^password\b/,"changes the admin password"], - [/^set prv\.key\b/,"replaces this node's identity"] + [/^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=[]; @@ -2019,13 +2024,14 @@ function cliAfter(cmds,r){ 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 — some commands failed. Fix them and run \"reboot\" again."); + cliEcho("err","Not rebooting — a setting was rejected. Fix it and run \"reboot\" again."); return; } if(r&&r.reboot){ @@ -2035,8 +2041,8 @@ function cliAfter(cmds,r){ // 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); + 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); }); } From cac91c91d8b59750f47a2f92625b7421627c116b Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 8 Aug 2026 08:53:56 -0700 Subject: [PATCH 11/16] feat(webconfig): print the full firmware version and name the channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console showed "v1.16.0" — the version was there but buried in the header line beside role and board, and on the build under test it genuinely had no more to show: `pio run` never goes through build.sh, so no build number, no commit, no OTA config. The banner now prints FIRMWARE_VERSION whole and on its own line. Nothing was truncating it; build.sh composes base[.build][-observer][-channel]-hash, so a CI build already carries the published build number as a 4th component and the commit as the trailing token — the two things that actually identify a build. It also names the channel, which the version string encodes but does not spell out (OTA_CHANNEL_TAG=beta-dev -> "-observer-beta-dev-"): v1.16.0.5-observer-beta-dev-a1b2c3d (dev channel) v1.16.0.5-observer-beta-a1b2c3d (beta channel) v1.16.0.5-observer-a1b2c3d (release channel) v1.16.0 (local build — not from CI, OTA not configured) That last one earns its wording: build.sh deliberately leaves OTA_MANIFEST_BASE undefined on local builds so such a node cannot update itself, and nothing about a bare version number says so. The mock reports a build.sh-shaped version now (--fw-version switches channel), and `ver` answers from the same string /api/status does, as both do on-device. --- scripts/webconfig_mock_server.py | 19 ++++++++++++++++--- webui/index.html | 18 +++++++++++++++++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index 5b94ce54..d4e00436 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -48,6 +48,11 @@ sys.path.insert(0, HERE) 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 @@ -179,7 +184,10 @@ 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, "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, @@ -560,7 +568,9 @@ def run_cli(cfg, line): if cmd == "": return True, "" if cmd == "ver": - return True, "v1.7.1-mock (observer)" + # 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": @@ -1005,15 +1015,18 @@ class Handler(BaseHTTPRequestHandler): def main(): - global ST, PORT, MINIFY + 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, 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 diff --git a/webui/index.html b/webui/index.html index 77174b1a..bebdc084 100644 --- a/webui/index.html +++ b/webui/index.html @@ -629,6 +629,7 @@ function boot(){ // 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.role=s.role||"";st.board=s.board||""; $("#h-name").textContent=s.name||"MeshCore"; $("#h-sub").textContent=s.role+" · "+s.fw+" · "+s.board; var b=$("#h-badge");b.classList.remove("hide"); @@ -1636,8 +1637,23 @@ function cliEcho(cls,text){ } function cliGap(){var d=cliEcho("gap","");return d} function cliClear(){$("#term-out").innerHTML="";cliBanner();$("#term-in").focus()} +// build.sh composes FIRMWARE_VERSION as base[.build][-observer][-channel]-hash, +// e.g. v1.16.0.5-observer-beta-dev-a1b2c3d. A plain `pio run` never goes through +// build.sh, so it keeps the header default (v1.16.0) with no build number, no +// hash, and — deliberately — no OTA_MANIFEST_BASE, so such a node cannot update +// itself. Worth saying out loud: the version alone does not make that obvious. +function cliChannel(fw){ + if(/-dev(-|$)/.test(fw))return"dev channel"; // OTA_CHANNEL_TAG=beta-dev + if(/-beta(-|$)/.test(fw))return"beta channel"; + if(/^v?\d+\.\d+\.\d+$/.test(fw))return"local build — not from CI, OTA not configured"; + return"release channel"; +} function cliBanner(){ - cliEcho("sys","MeshCore · "+($("#h-sub").textContent||"")); + // Full string, unabbreviated: the 4th component is the published build number + // and the trailing token is the commit, which is what identifies a build. + var ch=cliChannel(st.fw||""); + cliEcho("sys","MeshCore "+(st.fw||"unknown version")+(ch?" ("+ch+")":"")); + cliEcho("sys",(st.role||"")+" · "+(st.board||"")); 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 From 20826dcccf906d7bfc7a1baa33acf0f3bae0a311 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 8 Aug 2026 09:01:29 -0700 Subject: [PATCH 12/16] feat(webconfig): trim the displayed version to base, build and channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full embedded string is v1.16.0.5-observer-beta-dev-a1b2c3d. The -observer tag is on every observer build and the commit is for machines, so neither tells a person anything at a glance. Both the page header and the console banner now show base + published build number + channel, paired with the build date the way `ver` pairs them: v1.16.0.5-dev (6 Jun 2026) v1.16.0.5 (6 Jun 2026) `ver` still prints the whole string, commit included, for when that is what you need. The channel suffix follows the release filenames rather than the embedded tag — build.sh writes FILENAME_CHANNEL_TAG "-dev" for the same builds it tags "-observer-beta-dev" internally, so "-dev" is the name these already carry. Carrying the build date meant /api/status had to report it; WebConfigServer now takes FIRMWARE_BUILD_DATE alongside FIRMWARE_VERSION, from the same defines `ver` reads. A local build has neither build number nor channel to show, so the fact worth knowing about it moves to the second line: "local build, OTA not configured". build.sh deliberately leaves OTA_MANIFEST_BASE undefined there, and a bare version number gives no hint that the node cannot update itself. --- examples/simple_repeater/MyMesh.cpp | 2 +- scripts/webconfig_mock_server.py | 3 ++- src/helpers/esp32/WebConfigServer.cpp | 6 ++++- src/helpers/esp32/WebConfigServer.h | 3 ++- webui/index.html | 35 +++++++++++++-------------- 5 files changed, 27 insertions(+), 22 deletions(-) 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/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index d4e00436..3631cdc9 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -187,7 +187,8 @@ class State: # 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, "role": "Repeater", "board": "Heltec V3 (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, diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 903036cd..e2a279dc 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -112,9 +112,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(); } @@ -571,6 +572,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; diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h index 7ef23a92..e1191fc3 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(); @@ -125,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; diff --git a/webui/index.html b/webui/index.html index bebdc084..261d67e4 100644 --- a/webui/index.html +++ b/webui/index.html @@ -582,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||{}; @@ -629,9 +641,9 @@ function boot(){ // 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.role=s.role||"";st.board=s.board||""; + 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 @@ -1637,23 +1649,10 @@ function cliEcho(cls,text){ } function cliGap(){var d=cliEcho("gap","");return d} function cliClear(){$("#term-out").innerHTML="";cliBanner();$("#term-in").focus()} -// build.sh composes FIRMWARE_VERSION as base[.build][-observer][-channel]-hash, -// e.g. v1.16.0.5-observer-beta-dev-a1b2c3d. A plain `pio run` never goes through -// build.sh, so it keeps the header default (v1.16.0) with no build number, no -// hash, and — deliberately — no OTA_MANIFEST_BASE, so such a node cannot update -// itself. Worth saying out loud: the version alone does not make that obvious. -function cliChannel(fw){ - if(/-dev(-|$)/.test(fw))return"dev channel"; // OTA_CHANNEL_TAG=beta-dev - if(/-beta(-|$)/.test(fw))return"beta channel"; - if(/^v?\d+\.\d+\.\d+$/.test(fw))return"local build — not from CI, OTA not configured"; - return"release channel"; -} function cliBanner(){ - // Full string, unabbreviated: the 4th component is the published build number - // and the trailing token is the commit, which is what identifies a build. - var ch=cliChannel(st.fw||""); - cliEcho("sys","MeshCore "+(st.fw||"unknown version")+(ch?" ("+ch+")":"")); - cliEcho("sys",(st.role||"")+" · "+(st.board||"")); + 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 From 8abe26ba7b8e033de010f8b5e507457ccb6a4b6c Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 8 Aug 2026 14:05:02 -0700 Subject: [PATCH 13/16] fix(webconfig): stop the CLI reading secrets, and enforce the setup password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from review, both real, both mine. The CLI could read secrets the portal has never exposed. CommonCLI splits its surface by CALLER, not by command: a serial caller (sender_timestamp 0, physical access) reads secrets in plaintext, 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 at all, and with it the terminal inherited the serial console's plaintext answers for an HTTP request: `get prv.key` returned this node's identity, `get wifi.pwd` the operator's network. Worse in setup mode, which authenticates by proximity to an open AP — and `start webconfig ap` can be run on an already-configured node, so the secrets are real by then, not blank. I had reasoned that the AP was the trust boundary either way because the wizard can already rewrite these. That conflated two capabilities: replacing a WiFi password does not reveal the current one, and replacing an identity does not reveal the existing private key. /api/config has always masked these on read (wcIsSecretKey); the CLI simply broke that rule. Now only the READ is masked — the command surface stays whole — in CommonCLI's own words, keeping the set/unset signal that is the useful part. Onboarding could also skip the mandatory password. handleConfigPost refuses to arm a reboot during initial setup without one; the CLI only warned in the browser, which a pasted script or a direct POST ignores, so a node could reboot onto the LAN still holding the factory credential. Same rule now applies at POST. It is satisfied by a `password` command anywhere in the session rather than only in the same request, so the natural two-step console flow still works — the form batch always sends both together and never needed that memory. wcIsSecretReadCommand lives in WebConfigKeys.h beside the rest of the secret classification, pinned by three host tests: what must be masked, what must not, and that only reads are touched. 17 keys + 24 batch tests pass; the audit checks a masked read round-trips as masked. --- scripts/webconfig_cli_audit.py | 5 +- scripts/webconfig_mock_server.py | 26 +++++++++ src/helpers/WebConfigKeys.h | 22 ++++++++ src/helpers/esp32/WebConfigServer.cpp | 53 ++++++++++++++++++- src/helpers/esp32/WebConfigServer.h | 4 ++ .../test_webconfig_keys.cpp | 39 ++++++++++++++ webui/index.html | 6 ++- 7 files changed, 150 insertions(+), 5 deletions(-) diff --git a/scripts/webconfig_cli_audit.py b/scripts/webconfig_cli_audit.py index 14294334..3cd24db4 100644 --- a/scripts/webconfig_cli_audit.py +++ b/scripts/webconfig_cli_audit.py @@ -105,7 +105,10 @@ ROUND_TRIPS = [ ("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"), - ("set guest.password hunter2", "get guest.password", "********"), + # 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)"), ] diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index 3631cdc9..c38d804e 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -156,6 +156,7 @@ class State: 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 ------------------------------------------------------------- @@ -467,6 +468,17 @@ def cli_unavailable(cmd): # Failure replies CommonCLI emits that do NOT start with "Err" — the shapes that # made a naive prefix test call them success. Mirrors # WebConfigBatch::cliReplyIsFailure. +def cli_reads_secret(cmd): + """Commands that READ a secret. CommonCLI gates these on the caller being + the serial console; the portal is not, so the value is masked here the way + CommonCLI masks it for remote callers. Mirrors wcCliReadsSecret().""" + if not cmd.startswith("get "): + return False + key = cmd[4:].strip() + return key in ("prv.key", "guest.password", "alert.psk", "bridge.secret") \ + or is_secret_key(key) + + def cli_reply_is_failure(reply): if not reply: return False @@ -907,6 +919,15 @@ class Handler(BaseHTTPRequestHandler): 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) @@ -936,6 +957,11 @@ class Handler(BaseHTTPRequestHandler): _, 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). 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 e2a279dc..5aecb02e 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -79,6 +79,34 @@ static const char* wcCliUnavailable(const char* cmd) { 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. @@ -388,7 +416,13 @@ void WebConfigServer::drainBatch(uint32_t now) { // 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. // Config entries carry the key; a CLI entry is matched on the command. - if (wcIsAdminPasswordKey(e.key) || wcCliEchoesSecret(e.cmd)) strcpy(e.reply, "OK"); + 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 @@ -1047,7 +1081,7 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) { } int count = 0; - bool defer_reboot = false; + bool defer_reboot = false, seq_sets_pwd = false, seq_sets_ssid = false; for (JsonVariant v : cmds) { const char* raw = v.as(); if (!raw) continue; @@ -1089,6 +1123,8 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) { } e.key[0] = 0; // CLI entries have no config key if (wcIsDeferredReboot(e.cmd)) defer_reboot = true; + if (strncmp(e.cmd, "password ", 9) == 0) seq_sets_pwd = true; + if (strncmp(e.cmd, "set wifi.ssid ", 14) == 0) seq_sets_ssid = true; count++; } if (count == 0) { @@ -1096,6 +1132,19 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) { 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; diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h index e1191fc3..0dcebebd 100644 --- a/src/helpers/esp32/WebConfigServer.h +++ b/src/helpers/esp32/WebConfigServer.h @@ -136,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. 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/webui/index.html b/webui/index.html index 261d67e4..ab7b43d6 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1926,8 +1926,10 @@ function cliConfirm(cmds){ // 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"; - p.textContent="⚠ No password command has run here. Set the admin password before rebooting, "+ - "or the node keeps the factory one."; + // 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"; From 4bcbcd8ce0cd669346ec635a0e143ca922ab1c2d Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 8 Aug 2026 16:30:56 -0700 Subject: [PATCH 14/16] fix(webconfig): pass the build date from the room server too Adding build_date to the WebConfigServer constructor broke every *_room_server_observer_mqtt target: simple_room_server constructs the portal as well, and only simple_repeater was updated. Nothing caught it because every build run to that point had been a repeater target. --- examples/simple_room_server/MyMesh.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) { From 9988cb606364e827e661027bd95d402cb6b1e7f1 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 8 Aug 2026 16:37:26 -0700 Subject: [PATCH 15/16] chore: untrack the .wt-station-g3-prod worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A worktree checked out inside the repo, caught by a `git add -A` and recorded as a gitlink. Same problem df070833 fixed for .build-wt-028a5dca, and the same fix: untrack it and widen the ignore rule, which only covered .build-wt-*. It is not harmless. The gitlink pointed at a different commit than the worktree had checked out, so `git status` was permanently dirty — which is how it nearly got re-committed here, and it makes CI checkouts warn about a submodule path with no .gitmodules entry. The worktree directory is gone; branch fix/station-g3-ota-manifest-base and its commit are untouched. That branch still carries one commit not on prod — 5000391c, which adds OTA_MANIFEST_BASE to the station_g3 variant. It belongs on prod, whose build.sh does not yet inject that flag, and must NOT come to dev, where build.sh does and an .ini declaration cannot be overridden (SCons reorders -U/-D) — it would pin dev and beta builds to the production manifest. --- .gitignore | 7 +++++-- .wt-station-g3-prod | 1 - 2 files changed, 5 insertions(+), 3 deletions(-) delete mode 160000 .wt-station-g3-prod 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 From 6cfdc61baf5101f4b98cd8e5b85605eca5848c06 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 8 Aug 2026 17:13:00 -0700 Subject: [PATCH 16/16] fix(webconfig): define the in-class constants out of line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LilyGo_TLora_V2_1_1_6 observer builds failed to link: undefined reference to `WebConfigServer::MAX_BATCH' in handleStatus and handleCliPost An in-class initialiser is only a declaration under C++11, which is what the xtensa-esp32 toolchain builds with. Every previous use of MAX_BATCH was a comparison, which reads the value and needs no symbol. Reporting it as status.max_cmds, and naming it in the "too many commands" error, passes it to ArduinoJson — which takes `const T&` — and binding a reference odr-uses it. It linked on most targets because the compiler folded the reference away, and failed on the ones where it did not. A cast at the two call sites would have silenced it just as narrowly; defining the symbols is what stops the next use from depending on the same luck. MAX_BODY and STOP_WARN_MS get the same treatment for the same reason, before they are the next to be passed by reference. --- src/helpers/esp32/WebConfigServer.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 5aecb02e..34bdca4b 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -133,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.