Merge pull request #43 from agessaman/feat/webconfig-cli

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