fix(webconfig): make the mock answer the whole CLI surface

`get radio.fem.rxgain` returned "unknown config key" from the mock, which reads
as the terminal offering a command that does not exist. It does exist: CommonCLI
implements get and set for it, gated at runtime by Board::canControlLoRaFemLna()
rather than compiled out, so the command is present in every build and the board
answers for itself — "Error: unsupported" where there is no front-end module.

Auditing the whole table found 31 of 70 config keys unanswered, all the ones no
portal form drives: alert.*, bridge.*, owner.info, path.hash.mode, dutycycle and
the rest. Plus 14 verbs (gps, powersaving, sensor, region, clock sync) with no
handler at all. They now live in a "cli" section of the mock config, typed
through the existing lookup tables and stripped from /api/config, which does not
carry them.

Two real bugs behind that:

  - the `set` path gated on whether a key was *readable*, so write-only and
    computed keys (prv.key, dutycycle, radio.fem.rxgain) were rejected as
    unknown. apply_set now owns that decision alone.
  - apply_set accepted anything it did not recognise and replied OK. That
    leniency is what let the gap hide: a CLI `set` on an unknown key looked
    like it worked. It is strict now — verified against every key in
    WC_ALLOWED_SET_KEYS so the form batch is unaffected.

Also mqtt.neighbors / mqtt.neighbors.interval, which the MQTT tab binds but the
mock's config never carried, so that toggle could not round-trip.

webconfig_cli_audit.py keeps the two honest: it drives every command the
autocomplete table offers through /api/cli and fails on anything unanswered.
119 commands, all answered.
This commit is contained in:
agessaman
2026-08-07 22:52:15 -07:00
parent 105d71478d
commit b72b02f55b
2 changed files with 273 additions and 6 deletions
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Check the portal terminal's command table against the mock backend.
Autocomplete in webui/index.html carries its own list of commands. Nothing ties
that list to what a node actually answers, so it can quietly drift into offering
commands that do not exist — or, more often here, the mock can lag the table and
make a perfectly real command look broken.
This drives every command the table offers through /api/cli and reports the ones
that come back an error, so the two stay honest about each other.
python3 scripts/webconfig_mock_server.py --port 8137 &
python3 scripts/webconfig_cli_audit.py
Exits non-zero if anything fails that is not in EXPECTED_FAILURES. Stdlib only.
"""
import json
import os
import re
import secrets
import sys
import time
import urllib.error
import urllib.request
BASE = os.environ.get("WEBCONFIG_MOCK", "http://localhost:8137")
HERE = os.path.dirname(os.path.abspath(__file__))
INDEX_HTML = os.path.join(HERE, "..", "webui", "index.html")
# Errors that are the correct answer, not a gap.
EXPECTED_FAILURES = {
# Runtime-gated on the real device by Board::canControlLoRaFemLna(); the
# command exists in every build and the board answers for itself. The mock
# board is a Heltec V3, which has no front-end module.
"get radio.fem.rxgain": "unsupported",
"set radio.fem.rxgain on": "unsupported",
# Guarded by the firmware the same way when no alert PSK is configured.
"alert test": "not configured",
}
# Commands that change the node out from under the audit.
SKIP = {"reboot", "clkreboot", "poweroff", "shutdown", "erase", "start ota",
"stop webconfig", "ota update", "start webconfig", "start webconfig ap"}
def table():
"""The commands autocomplete offers, read straight out of the page."""
html = open(INDEX_HTML, encoding="utf-8").read()
def section(start, end):
return html[html.index(start):html.index(end)]
verbs = re.findall(r'\["([^"]+)","', section("var CLI_VERBS=", "var CLI_KEYS="))
keys = re.findall(r'\["([^"]+)","(?:[^"\\]|\\.)*",(\d)',
section("var CLI_KEYS=", "var CLI_SLOT="))
fields = re.findall(r'\["(\w+)","', section("var CLI_SLOT=", "var CLI_TYPES="))
gets = ["get " + k for k, mode in keys if mode != "2"]
gets += ["get mqtt%d.%s" % (n, f) for n in (1, 3) for f in fields]
# Verbs taking an argument need a value the node will accept; those are
# covered by the round-trip probes below rather than guessed at here.
plain = [v for v in verbs if not v.endswith(" ") and v not in SKIP]
return gets + plain
# set -> get pairs, checking a value survives the round trip.
ROUND_TRIPS = [
("set radio.watchdog 30", "get radio.watchdog", "30"),
("set dutycycle 25", "get dutycycle", "25.0"),
("set alert.mqtt on", "get alert.mqtt", "on"),
("set bridge.source tx", "get bridge.source", "tx"),
("set mqtt.neighbors on", "get mqtt.neighbors", "on"),
("set path.hash.mode 2", "get path.hash.mode", "2"),
("set mqtt.iata den", "get mqtt.iata", "DEN"),
("set guest.password hunter2", "get guest.password", "********"),
]
class Client:
def __init__(self, base):
self.base = base
r = self._open("/api/login", b'{"password":"password"}')
self.cookie = r.headers["Set-Cookie"].split(";")[0]
def _open(self, path, data=None):
headers = {"Content-Type": "application/json"}
if getattr(self, "cookie", None):
headers["Cookie"] = self.cookie
return urllib.request.urlopen(urllib.request.Request(
self.base + path, data=data, headers=headers,
method="POST" if data is not None else "GET"))
def run(self, cmds, chunk=40):
out = []
for i in range(0, len(cmds), chunk):
out += self._sequence(cmds[i:i + chunk])
return out
def _sequence(self, cmds):
reqid = secrets.token_hex(8)
body = json.dumps({"reqid": reqid, "cmds": cmds}).encode()
for _ in range(200): # the executor frees itself in time
try:
self._open("/api/cli", body)
break
except urllib.error.HTTPError as e:
if e.code != 409:
raise
time.sleep(0.5)
while True:
r = json.load(self._open("/api/cli/result?reqid=" + reqid))
if r["state"] == "done":
return r["results"]
time.sleep(0.05)
def main():
try:
cli = Client(BASE)
except OSError as e:
sys.exit("cannot reach the mock at %s (%s)\n"
"start it with: python3 scripts/webconfig_mock_server.py --port 8137" % (BASE, e))
failures = []
cmds = table()
unexpected = []
for res in cli.run(cmds):
if res["ok"]:
continue
want = EXPECTED_FAILURES.get(res["cmd"])
if want and want in res["reply"]:
continue
unexpected.append((res["cmd"], res["reply"]))
print("commands offered by autocomplete : %d" % len(cmds))
print("answered : %d" % (len(cmds) - len(unexpected)))
for cmd, reply in unexpected:
print(" FAIL %-30s %s" % (cmd, reply))
failures += unexpected
results = cli.run([c for probe in ROUND_TRIPS for c in probe[:2]])
print("\nround-trips : %d" % len(ROUND_TRIPS))
for i, (setc, getc, want) in enumerate(ROUND_TRIPS):
setr, getr = results[i * 2], results[i * 2 + 1]
if setr["ok"] and getr["reply"] == want:
continue
print(" FAIL %-30s got %r, wanted %r (set: %s)"
% (getc, getr["reply"], want, setr["reply"]))
failures.append((getc, getr["reply"]))
print("\n%s" % ("FAILED: %d" % len(failures) if failures else "all clear"))
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+116 -6
View File
@@ -113,8 +113,23 @@ def default_config(setup_mode):
"interval": 5, "timezone": "MST7MDT,M3.2.0,M11.1.0", "timezone_offset": -7,
"ntp": "pool.ntp.org", "owner": "", "email": "", "snmp": False,
"snmp_community": "public",
"neighbors": False, "neighbors_interval": 24,
"slots": [_slot() for _ in range(6)],
},
# Settings the CLI reaches but no portal form does, so they are absent
# from /api/config (see config_json) and live only here. Without them
# the terminal answers "unknown config key" for perfectly real commands.
"cli": {
"radio.watchdog": 0, "int.thresh": 0, "agc.reset.interval": 0,
"direct.txdelay": 0.0, "multi.acks": 0, "allow.read.only": False,
"path.hash.mode": 0, "owner.info": "", "guest.password": "",
"adc.multiplier": 1.0,
"alert": False, "alert.psk": "", "alert.hashtag": "",
"alert.region": "", "alert.interval": 15,
"alert.mqtt": False, "alert.wifi": False,
"bridge.enabled": False, "bridge.source": "rx", "bridge.baud": 115200,
"bridge.delay": 0, "bridge.channel": 0, "bridge.secret": "",
},
}
@@ -151,6 +166,7 @@ class State:
# ---- config serialization (masks secrets, like handleConfigGet) -------
def config_json(self):
c = copy.deepcopy(self.cfg)
c.pop("cli") # CLI-only settings: not part of this contract
c["wifi"]["pwd"] = SENTINEL if self.cfg["wifi"]["pwd"] else ""
for s in c["mqtt"]["slots"]:
s["password"] = SENTINEL if s["password"] else ""
@@ -176,13 +192,15 @@ class State:
BOOL_KEYS = {"cad": ("radio", "cad"), "radio.rxgain": ("radio", "rxgain"),
"repeat": ("radio", "repeat"), "mqtt.status": ("mqtt", "status"),
"mqtt.packets": ("mqtt", "packets"), "mqtt.raw": ("mqtt", "raw"),
"mqtt.rx": ("mqtt", "rx"), "snmp": ("mqtt", "snmp")}
"mqtt.rx": ("mqtt", "rx"), "snmp": ("mqtt", "snmp"),
"mqtt.neighbors": ("mqtt", "neighbors")}
INT_KEYS = {"tx": ("radio", "tx"), "flood.max": ("radio", "flood_max"),
"flood.max.advert": ("radio", "flood_max_advert"),
"flood.max.unscoped": ("radio", "flood_max_unscoped"),
"advert.interval": ("radio", "advert_interval"),
"flood.advert.interval": ("radio", "flood_advert_interval"),
"mqtt.interval": ("mqtt", "interval"),
"mqtt.neighbors.interval": ("mqtt", "neighbors_interval"),
"timezone.offset": ("mqtt", "timezone_offset")}
FLOAT_KEYS = {"lat": ("radio", "lat"), "lon": ("radio", "lon"),
"af": ("radio", "af"), "rxdelay": ("radio", "rxdelay"),
@@ -194,6 +212,16 @@ STR_KEYS = {"name": ("radio", "name"), "wifi.ssid": ("wifi", "ssid"),
"snmp.community": ("mqtt", "snmp_community"), "mqtt.tx": ("mqtt", "tx")}
SECRET_STR_KEYS = {"wifi.pwd": ("wifi", "pwd")}
# The CLI-only settings, typed the same way so apply_set/cli_read_key reach them
# through the existing lookups rather than a parallel code path.
for _k, _v in default_config(False)["cli"].items():
_t = {bool: BOOL_KEYS, int: INT_KEYS, float: FLOAT_KEYS, str: STR_KEYS}[type(_v)]
_t[_k] = ("cli", _k)
SECRET_STR_KEYS.update({k: ("cli", k) for k in
("guest.password", "alert.psk", "bridge.secret")})
for _k in SECRET_STR_KEYS:
STR_KEYS.pop(_k, None)
def _hex64(v):
return len(v) == 64 and all(c in "0123456789abcdefABCDEF" for c in v)
@@ -214,6 +242,19 @@ def apply_set(cfg, key, val):
ADMIN_PASSWORD = val
return True, "OK"
if key == "radio.fem.rxgain":
return False, "Error: unsupported" # no FEM on the mock board, see GETTERS
if key == "dutycycle":
try:
dc = float(val)
except ValueError:
return False, "Error: expected a number"
if not 0 < dc <= 100:
return False, "Error, must be 1-100"
cfg["radio"]["af"] = 100.0 / dc - 1 # the CLI stores it as airtime_factor
return True, "OK"
if key in ("freq", "bw", "sf", "cr"):
# single-component radio setters, reachable from the CLI but not from
# the form batch (which always sends the whole `radio` combo)
@@ -243,6 +284,12 @@ def apply_set(cfg, key, val):
cfg["mqtt"]["iata"] = val.upper()
return True, "OK"
if key == "prv.key":
# write-only by design: the identity goes in, nothing reads it back
if not _hex64(val):
return False, "Error: private key must be 64 hex characters"
return True, "OK - identity restored, reboot to apply"
if key == "mqtt.owner":
if val == "":
cfg["mqtt"]["owner"] = ""
@@ -282,7 +329,10 @@ def apply_set(cfg, key, val):
sec, f = STR_KEYS[key]
cfg[sec][f] = val
return True, "OK"
return True, "OK" # unknown-but-allowlisted: accept (mock is lenient here)
# Strict fallthrough: this function is the single authority on what can be
# set, for the batch and the CLI alike. Accepting unknown keys here once hid
# the fact that the CLI could not reach `dutycycle` or `radio.fem.rxgain`.
return False, "Error: unknown config key '%s'" % key
# Payload-type names accepted alongside the decimal form. Mirrors
@@ -360,7 +410,9 @@ def apply_slot_set(cfg, idx, field, val):
def is_secret_key(key):
return key == "wifi.pwd" or bool(re.match(r"^mqtt[1-6]\.(password|token)$", key))
# The serial console prints these back; the portal is reachable over the
# LAN, so it masks them in `get` replies the way /api/config already does.
return key in SECRET_STR_KEYS or bool(re.match(r"^mqtt[1-6]\.(password|token)$", key))
# ---------------------------------------------------------------------------
@@ -389,6 +441,18 @@ GETTERS = {
"mqtt.presets": lambda c: "\n".join(
"%2d. %s%s" % (i + 1, n, "" if nd == "none" else " (needs %s)" % nd)
for i, (n, nd) in enumerate(PRESETS)),
"role": lambda c: "Repeater",
# not its own pref: the CLI derives it from airtime_factor both ways
"dutycycle": lambda c: "%.1f" % (100.0 / (c["radio"]["af"] + 1)),
"mqtt.config.valid": lambda c: (
"yes" if any(s["preset"] != "none" for s in c["mqtt"]["slots"]) else "no - no slot configured"),
"mqtt.ntp.diag": lambda c: "last sync: 42s ago via %s (offset +0.011s)" % (c["mqtt"]["ntp"] or "none"),
"mqtt.stats": lambda c: ("published: %d\ndropped: 0\nqueue: 0/24\nreconnects: 1"
% (100 + int(time.time() - ST.start))),
# Runtime-gated on the real device (Board::canControlLoRaFemLna), not
# compiled out — the command exists everywhere and the board answers for
# itself. The mock board is a Heltec V3, which has no FEM.
"radio.fem.rxgain": lambda c: None,
}
@@ -405,7 +469,8 @@ def cli_mqtt_status(cfg):
def cli_get(cfg, key):
if key in GETTERS:
return True, GETTERS[key](cfg)
val = GETTERS[key](cfg)
return (True, val) if val is not None else (False, "Error: unsupported")
if is_secret_key(key):
# The serial console prints these; the portal is reachable over the LAN,
# so it masks them the same way /api/config does.
@@ -464,6 +529,50 @@ def run_cli(cfg, line):
if cmd == "neighbors":
return True, ("d4e5f60718 -71 dBm snr 9.5 2m ago\n"
"1122334455 -94 dBm snr 2.0 14m ago")
if cmd == "clock sync":
return True, "OK - clock set: %s UTC" % time.strftime("%H:%M - %d/%m/%Y", time.gmtime())
if cmd == "region":
return True, "US915"
if cmd == "sensor list":
return True, "0: battery (mV)\n1: temperature (C)\n2: humidity (%)"
if cmd.startswith("sensor get "):
return True, "> 22.4"
if cmd.startswith("sensor set "):
return True, "OK"
if cmd.startswith("gps advert "):
mode = cmd[11:]
if mode not in ("none", "share", "prefs"):
return False, "Error, must be none, share or prefs"
return True, "OK - advert position: %s" % mode
if cmd in ("gps on", "gps off"):
return True, "OK - GPS %s" % cmd[4:]
if cmd == "gps sync":
return True, "OK - clock and location set from GPS"
if cmd == "gps setloc":
return True, "OK - lat/lon set from the current fix"
if cmd == "gps":
return True, "GPS: no fix (0 satellites)"
if cmd in ("powersaving on", "powersaving off"):
return True, "OK - power saving %s" % cmd[12:]
if cmd == "powersaving":
return True, "off"
if cmd.startswith("alert test"):
if not ST.cfg["cli"]["alert.psk"]:
return False, "Error: alert channel not configured (set alert.psk or set alert.hashtag)"
return True, "OK - test alert sent"
if cmd.startswith("ota "):
return True, ("v1.7.2 available (current v1.7.1-mock)" if cmd == "ota check"
else "OK - downloading v1.7.2, will reboot when flashed")
if cmd.startswith("start webconfig"):
return True, "OK - already running (you are using it)"
if cmd == "stop webconfig":
return True, "OK - portal stopping"
if cmd == "start ota":
return True, "OK - upload AP raised at 192.168.4.1"
if cmd.startswith("neighbor.remove "):
return (True, "OK") if _hex64(cmd[16:]) else (False, "ERR: bad pubkey")
if cmd.startswith("tempradio "):
return True, "OK - temporary radio params applied (not saved)"
if cmd == "clear stats":
return True, "OK - stats cleared"
if cmd.startswith("stats-"):
@@ -485,8 +594,9 @@ def run_cli(cfg, line):
key, _, val = rest.partition(" ")
if not key:
return False, "Error: set what?"
if cli_read_key(cfg, key) is None and not re.match(r"^mqtt[1-6]\.", key):
return False, "Error: unknown config key '%s'" % key
# apply_set owns the "is this settable" decision; gating on whether the
# key is *readable* rejected write-only and computed ones (`dutycycle`,
# `prv.key`, `radio.fem.rxgain`).
return apply_set(cfg, key, val.strip())
return False, "Error: unknown command '%s'" % cmd[:40]