feat(webconfig): implement /api/cli on the device

The terminal has been driving the mock since it was built. This is the firmware
side, so it works on hardware.

Same 202 + reqid + poll contract as a config save, for the same reason:
CommonCLI touches prefs, the radio and the filesystem, none of which may be
reached from the async_tcp task. Commands go into the deferred slot and tick()
drains them on the loop task. Unlike a save this is not allowlisted — reaching
what the serial console reaches is the point, and execCommand() already passes
sender_timestamp 0, so the terminal gets exactly the serial console's
privilege. Authentication is the boundary, as it is there.

The CLI shares the config batch's slot rather than owning a second MAX_BATCH
array: both drain on the loop task, both are single-slot, and a duplicate would
cost ~8 KB of permanently resident RAM. Sharing also makes a save and a CLI run
mutually exclusive, which they must be. Each reader checks the kind, so neither
can serve the other's results.

Three things the mock could not have taught us:

  - Board::reboot() does not return, so a drained `reboot` would take the node
    down before the client read a single result. It is answered rather than
    executed, and the batch arms the existing deferred-reboot path once the
    results have been read — withheld if any command failed, exactly as a save
    withholds one. clkreboot/poweroff/ota update do real work on the way down
    and cannot be faked, so they still drop the connection; the UI warns first.
  - `password <new>` echoes the new password in its reply. The config path
    already scrubbed that by key; a CLI entry has no key, so it is matched on
    the command. CLI commands are also kept out of the serial log entirely —
    the browser session and the serial console are different audiences.
  - MAX_BATCH is 24, not the 64 the page assumed. It is reported as
    status.max_cmds instead of hardcoded, so the cap cannot drift.

Results stream and page (kCliResultPage = 8), and "done" means the client has
been handed every result, not merely that execution finished — otherwise a
client that stops polling at "done" loses the last page. Commands are never
echoed back: they may carry a secret, and the client matches by index.

New decisions live in WebConfigBatch.h with the rest, covered by three host
tests. Builds clean for heltec_v4_repeater_observer_mqtt; 22 batch + 14 keys
tests pass; the CLI audit reports 119/119 against the updated mock.
This commit is contained in:
agessaman committed 2026-08-07 23:16:38 -07:00
1 parent 58b8b90560
commit 556d02f765
7 files changed
+446 -37

No files matched your search

+23 -9
View File
@@ -82,6 +82,9 @@ class Client:
self.base = base
r = self._open("/api/login", b'{"password":"password"}')
self.cookie = r.headers["Set-Cookie"].split(";")[0]
# The node caps a sequence at MAX_BATCH and reports it; chunk to match
# rather than hardcoding a number that drifts when the slot is resized.
self.max_cmds = json.load(self._open("/api/status")).get("max_cmds", 24)
def _open(self, path, data=None):
headers = {"Content-Type": "application/json"}
@@ -91,10 +94,16 @@ class Client:
self.base + path, data=data, headers=headers,
method="POST" if data is not None else "GET"))
def run(self, cmds, chunk=40):
def run(self, cmds):
"""[(command, result)]. The node never echoes the command back — it may
carry a secret — so results pair with what was sent, by index."""
out = []
for i in range(0, len(cmds), chunk):
out += self._sequence(cmds[i:i + chunk])
for i in range(0, len(cmds), self.max_cmds):
chunk = cmds[i:i + self.max_cmds]
results = self._sequence(chunk)
if len(results) != len(chunk):
sys.exit("node returned %d results for %d commands" % (len(results), len(chunk)))
out += list(zip(chunk, results))
return out
def _sequence(self, cmds):
@@ -108,10 +117,14 @@ class Client:
if e.code != 409:
raise
time.sleep(0.5)
# Results stream and page, so keep reading from a cursor until the node
# says done — "done" arrives only once every result has been handed over.
out = []
while True:
r = json.load(self._open("/api/cli/result?reqid=" + reqid))
r = json.load(self._open("/api/cli/result?reqid=%s&from=%d" % (reqid, len(out))))
out += r.get("results", [])
if r["state"] == "done":
return r["results"]
return out
time.sleep(0.05)
@@ -126,15 +139,16 @@ def main():
cmds = table()
unexpected = []
for res in cli.run(cmds):
for cmd, res in cli.run(cmds):
if res["ok"]:
continue
want = EXPECTED_FAILURES.get(res["cmd"])
want = EXPECTED_FAILURES.get(cmd)
if want and want in res["reply"]:
continue
unexpected.append((res["cmd"], res["reply"]))
unexpected.append((cmd, res["reply"]))
print("commands offered by autocomplete : %d" % len(cmds))
print("answered : %d" % (len(cmds) - len(unexpected)))
print("sequence cap reported by the node: %d" % cli.max_cmds)
for cmd, reply in unexpected:
print(" FAIL %-30s %s" % (cmd, reply))
failures += unexpected
@@ -142,7 +156,7 @@ def main():
results = cli.run([c for probe in ROUND_TRIPS for c in probe[:2]])
print("\nround-trips : %d" % len(ROUND_TRIPS))
for i, (setc, getc, want) in enumerate(ROUND_TRIPS):
setr, getr = results[i * 2], results[i * 2 + 1]
setr, getr = results[i * 2][1], results[i * 2 + 1][1]
if setr["ok"] and getr["reply"] == want:
continue
print(" FAIL %-30s got %r, wanted %r (set: %s)"
+35 -9
View File
@@ -182,6 +182,7 @@ class State:
"fw": "v1.7.1-mock", "role": "Repeater", "board": "Heltec V3 (mock)",
"uptime_s": int(time.time() - self.start),
"runtime_slots": 6, "max_slots": 6, "active_slots": self.active_slots,
"max_cmds": CLI_MAX_CMDS,
}
@@ -424,8 +425,14 @@ def is_secret_key(key):
# the same surface the serial console reaches. Auth is the boundary — exactly
# as it is for the serial console and for remote admin over the mesh.
# ---------------------------------------------------------------------------
CLI_MAX_CMDS = 64 # per POSTed sequence
# MAX_BATCH in WebConfigServer.h: the CLI shares the config batch's fixed slot,
# so this is the real cap, reported to the page as status.max_cmds.
CLI_MAX_CMDS = 24
CLI_RESULT_PAGE = 8 # WebConfigBatch::kCliResultPage
CLI_CMD_SECS = 0.25 # simulated per-command execution time
# Board::reboot() does not return, so the firmware answers `reboot` itself and
# arms the deferred reboot once results have been read (see wcIsDeferredReboot).
CLI_DEFERRED_REBOOT = "reboot"
# Commands the device answers but that have no config-key equivalent.
GETTERS = {
@@ -821,14 +828,14 @@ class Handler(BaseHTTPRequestHandler):
if not isinstance(cmds, list) or not cmds:
return self._json(400, {"error": "no commands"})
if len(cmds) > CLI_MAX_CMDS:
return self._json(413, {"error": "too many commands (max %d)" % CLI_MAX_CMDS})
return self._json(413, {"error": "too many commands", "max": CLI_MAX_CMDS})
cmds = [str(c).replace("\r", "").replace("\n", "").strip() for c in cmds]
cmds = [c for c in cmds if c]
if not cmds:
return self._json(400, {"error": "no commands"})
for c in cmds:
if len(c) > BATCH_CMD_SIZE - 1:
return self._json(400, {"error": "command too long", "cmd": c[:32]})
return self._json(400, {"error": "command too long"})
with ST.lock:
self._cli_advance(ST.cli)
@@ -838,6 +845,7 @@ class Handler(BaseHTTPRequestHandler):
if ST.cli.get("state") == "running":
return self._json(409, {"error": "busy", "reqid": ST.cli.get("reqid", "")})
ST.cli = {"state": "running", "reqid": reqid, "cmds": cmds, "results": [],
"all_ok": True, "reboot": CLI_DEFERRED_REBOOT in cmds,
"next_at": time.time() + CLI_CMD_SECS}
return self._json(202, {"state": "running", "reqid": reqid, "total": len(cmds)})
@@ -850,8 +858,16 @@ class Handler(BaseHTTPRequestHandler):
while (job.get("state") == "running" and len(job["results"]) < len(job["cmds"])
and now >= job["next_at"]):
cmd = job["cmds"][len(job["results"])]
ok, reply = run_cli(ST.cfg, cmd)
job["results"].append({"cmd": cmd, "ok": ok, "reply": reply})
if cmd == CLI_DEFERRED_REBOOT:
ok, reply = True, "OK - reboot queued"
else:
ok, reply = run_cli(ST.cfg, cmd)
if cmd.startswith("password "):
reply = "OK" # never echo the new password back
job["all_ok"] = job.get("all_ok", True) and ok
# The command is NOT echoed: it may carry a password or token, and
# the client matches results to its own sequence by index.
job["results"].append({"ok": ok, "reply": reply})
job["next_at"] = now + CLI_CMD_SECS
if job.get("state") == "running" and len(job["results"]) == len(job["cmds"]):
job["state"] = "done" # stays readable until the next POST
@@ -874,10 +890,20 @@ class Handler(BaseHTTPRequestHandler):
if j.get("reqid") != reqid:
return self._json(404, {"error": "unknown request"})
self._cli_advance(j) # one command per CLI_CMD_SECS
return self._json(200, {
"state": j["state"], "reqid": reqid, "total": len(j["cmds"]),
"from": frm, "results": j["results"][frm:],
})
# Results stream, capped per read so the device's JSON document
# stays small; a longer sequence pages across reads. "done" means
# the client has been handed everything, not just that execution
# finished — a client that stops polling at "done" must lose nothing.
page = j["results"][frm:frm + CLI_RESULT_PAGE]
final = j["state"] == "done" and frm + len(page) >= len(j["cmds"])
body = {"state": "done" if final else "running", "reqid": reqid,
"total": len(j["cmds"]), "from": frm, "results": page}
if final:
body["all_ok"] = j["all_ok"]
body["reboot"] = j["reboot"] and j["all_ok"]
if j["reboot"] and not j["all_ok"]:
body["reboot_withheld"] = True
return self._json(200, body)
def _scan(self):
rescan = "rescan=1" in self.path