From cbbd01b620a0fbf58a01073e677bc08dabe3af38 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 18 Jul 2026 13:19:27 -0700 Subject: [PATCH] feat(docs): add local testing instructions for MQTT functionality Include detailed instructions for local testing of observer and WiFi functionality without hardware. Document the use of a mock backend and Wokwi ESP32-S3 simulation for easier development and testing. Enhance the MQTT implementation documentation to improve developer experience and facilitate testing workflows. --- MQTT_IMPLEMENTATION.md | 20 + diagram.json | 18 + examples/simple_repeater/MyMesh.cpp | 19 + platformio.ini | 2 + .../webconfig_mock_server.cpython-312.pyc | Bin 0 -> 25474 bytes scripts/webconfig_mock_server.py | 470 ++++++++++++++++++ src/helpers/CommonCLI_Observer.cpp | 68 ++- src/helpers/MQTTObserverValidation.h | 60 +++ src/helpers/MQTTPresets.h | 3 + src/helpers/MQTTTopicTemplate.h | 52 ++ src/helpers/WebConfigKeys.h | 62 +++ src/helpers/bridges/MQTTBridge.cpp | 43 +- src/helpers/esp32/WebConfigServer.cpp | 52 +- src/helpers/sim/SimRadio.h | 75 +++ test/README.md | 48 ++ test/test_mqtt_presets/test_mqtt_presets.cpp | 138 +++++ .../test_observer_validation.cpp | 135 +++++ .../test_topic_template.cpp | 101 ++++ .../test_webconfig_keys.cpp | 104 ++++ variants/heltec_v3/platformio.ini | 10 + variants/heltec_v3/target.cpp | 15 +- variants/heltec_v3/target.h | 23 +- wokwi.toml | 16 + 23 files changed, 1403 insertions(+), 131 deletions(-) create mode 100644 diagram.json create mode 100644 scripts/__pycache__/webconfig_mock_server.cpython-312.pyc create mode 100644 scripts/webconfig_mock_server.py create mode 100644 src/helpers/MQTTObserverValidation.h create mode 100644 src/helpers/MQTTTopicTemplate.h create mode 100644 src/helpers/WebConfigKeys.h create mode 100644 src/helpers/sim/SimRadio.h create mode 100644 test/README.md create mode 100644 test/test_mqtt_presets/test_mqtt_presets.cpp create mode 100644 test/test_observer_validation/test_observer_validation.cpp create mode 100644 test/test_topic_template/test_topic_template.cpp create mode 100644 test/test_webconfig_keys/test_webconfig_keys.cpp create mode 100644 wokwi.toml diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 21735931..0da06dc5 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -534,6 +534,26 @@ serial and use the CLI directly (e.g. `set wifi.ssid ...`, `set wifi.pwd ...`, `get wifi.status`, `stop webconfig`). Serial access always works regardless of the portal state. +### Local testing without hardware + +Two ways to iterate on observer/WiFi functionality without flashing a device: + +- **Portal UI** — run the mock backend and open the real portal in a browser: + `python3 scripts/webconfig_mock_server.py` (add `--setup` for the first-boot + wizard), then browse to `http://localhost:8080/`. It serves `webui/index.html` + and mirrors the firmware's `/api/*` contract (reqid handshake, reboot gating, + validation, secret masking), so the portal JS runs against realistic + responses. Stdlib only; no account. +- **Boot / WiFi / MQTT / CLI / OLED** — the Wokwi ESP32-S3 sim. Build + `pio run -e Heltec_v3_repeater_observer_mqtt_sim -t mergebin` (LoRa radio + stubbed via `SimRadio`, WiFi pre-seeded to `Wokwi-GUEST`), then run the sim + from `wokwi.toml`/`diagram.json` (VS Code Wokwi extension or `wokwi-cli`). + Outbound MQTT works on the free gateway; incoming (browser → on-device portal) + needs Wokwi's paid Private Gateway — use the mock backend above for portal UI. + +Backend handler logic is covered by host unit tests under `test/` (`pio test -e +native`); see [test/README.md](test/README.md) for the suites and how to run them. + ## Command Architecture The CLI commands are organized into two levels: diff --git a/diagram.json b/diagram.json new file mode 100644 index 00000000..046f6de8 --- /dev/null +++ b/diagram.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "author": "MeshCore", + "editor": "wokwi", + "parts": [ + { "type": "board-esp32-s3-devkitc-1", "id": "esp", "top": 0, "left": 0, "attrs": {} }, + { "type": "board-ssd1306", "id": "oled", "top": -110, "left": 90, + "attrs": { "i2cAddress": "0x3c" } } + ], + "connections": [ + [ "esp:3V3", "oled:VCC", "red", [] ], + [ "esp:GND.1", "oled:GND", "black", [] ], + [ "esp:17", "oled:SDA", "green", [] ], + [ "esp:18", "oled:SCL", "yellow", [] ], + [ "esp:TX", "$serialMonitor:RX", "", [] ], + [ "esp:RX", "$serialMonitor:TX", "", [] ] + ] +} diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 63fd50a8..e2467c1b 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -995,6 +995,25 @@ void MyMesh::begin(FILESYSTEM *fs) { // load persisted prefs _cli.loadPrefs(_fs); +#ifdef SIM_WIFI_SSID + // Emulator builds (Wokwi) boot with fresh NVS every run. Seed WiFi so the + // observer auto-joins the simulator's network and brings the MQTT bridge up + // (WiFi is driven by the bridge task), instead of raising the setup AP that + // the emulator can't model. No-op for real firmware (flag never defined). + { + MQTTPrefs* obs = _cli.getObserverPrefs(); + if (obs->wifi_ssid[0] == 0) { + strncpy(obs->wifi_ssid, SIM_WIFI_SSID, sizeof(obs->wifi_ssid) - 1); + obs->wifi_ssid[sizeof(obs->wifi_ssid) - 1] = 0; + #ifdef SIM_WIFI_PWD + strncpy(obs->wifi_password, SIM_WIFI_PWD, sizeof(obs->wifi_password) - 1); + obs->wifi_password[sizeof(obs->wifi_password) - 1] = 0; + #endif + _prefs.bridge_enabled = 1; // WiFi comes up via the MQTT bridge task + } + } +#endif + acl.load(_fs, self_id); // TODO: key_store.begin(); region_map.load(_fs); diff --git a/platformio.ini b/platformio.ini index b2d0c662..102d486c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -160,6 +160,8 @@ lib_deps = adafruit/Adafruit BMP085 Library @ ^1.2.4 ; ----------------- TESTING --------------------- +; Host GoogleTest suites for the fork's pure logic. See test/README.md. +; Run: `pio test -e native` (all) or `pio test -e native -f ` (one). [env:native] platform = native diff --git a/scripts/__pycache__/webconfig_mock_server.cpython-312.pyc b/scripts/__pycache__/webconfig_mock_server.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4e5d4f9e8cd3758d66a91e69a881b5adc0761baf GIT binary patch literal 25474 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 VL&>l1UvSXSDYtU2u(vlG{x4PSN! done result polling, aggregate-success reboot gating, secret masking +(********), and the IATA / owner-key / length validation the firmware enforces. +So the browser drives the actual portal JS (wizard, save/poll/reqid, effective +value handling, reboot overlay, stats, scan) against realistic responses. + +It does NOT run the C++ handlers (that's what test/ gtest covers) or the +AsyncTCP transport — it's a frontend + contract harness. + +Usage: + python3 scripts/webconfig_mock_server.py # LAN mode (login: password) + python3 scripts/webconfig_mock_server.py --setup # first-boot setup wizard + python3 scripts/webconfig_mock_server.py --port 9000 --active-slots 2 +Then open http://localhost:8080/ (or the chosen port). Editing index.html and +refreshing shows changes immediately — the page is re-read per request. + +Stdlib only; no pip install. +""" + +import argparse +import copy +import json +import os +import re +import secrets +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +HERE = os.path.dirname(os.path.abspath(__file__)) +INDEX_HTML = os.path.join(HERE, "..", "webui", "index.html") + +SENTINEL = "********" +ADMIN_PASSWORD = "password" # matches the default ADMIN_PASSWORD build flag +BATCH_PENDING_SECS = 0.8 # how long POST->done takes, to exercise polling +SCAN_SECS = 0.8 + +# Destination buffer sizes (chars, minus the NUL) — mirrors the MQTTPrefs fields +# the firmware validates in CommonCLI_Observer.cpp. +LEN_LIMITS = { + "name": 31, "wifi.ssid": 31, "wifi.pwd": 63, "mqtt.origin": 31, + "mqtt.email": 63, "mqtt.ntp": 63, "timezone": 31, "snmp.community": 23, +} +SLOT_LEN_LIMITS = {"server": 63, "username": 31, "password": 63, + "token": 47, "topic": 95, "audience": 63} + +# Preset names + what the UI must collect (mirrors handlePresets()). +PRESETS = ( + [(n, "none") for n in ( + "analyzer-us", "analyzer-eu", "nz-analyzer", "meshmapper", "waev", + "meshomatic", "cascadiamesh", "tennmesh", "nashmesh", "ctmesh", "chimesh", + "meshat.se", "eastidahomesh", "coloradomesh", "dutchmeshcore-1", + "dutchmeshcore-2", "meshcore-ca-1", "meshcore-ca-2", "meshcore-fi", + "bostonmesh", "rflab", "ipnt.uk", "flmesh", "corecomms")] + + [("meshrank", "token"), ("inwmesh", "userpass")] +) + +SCAN_NETWORKS = [ + {"ssid": "Wokwi-GUEST", "rssi": -42, "enc": False}, + {"ssid": "HomeNet", "rssi": -55, "enc": True}, + {"ssid": "HomeNet-5G", "rssi": -61, "enc": True}, + {"ssid": "Neighbor 2.4", "rssi": -78, "enc": True}, + {"ssid": "OpenGuest", "rssi": -83, "enc": False}, +] + + +def default_config(setup_mode): + return { + "radio": { + "freq": 910.525, "bw": 62.5, "sf": 7, "cr": 5, "tx": 22, "af": 1.0, + "rxdelay": 0.0, "txdelay": 0.5, "cad": False, "rxgain": True, + "repeat": True, "flood_max": 64, "flood_max_advert": 8, + "flood_max_unscoped": 8, "loop_detect": "moderate", + "name": "MockNode", "lat": 39.7392, "lon": -104.9903, + "advert_interval": 240, "flood_advert_interval": 6, + }, + "wifi": { + # setup mode = unconfigured (empty ssid -> wizard); LAN mode = joined + "ssid": "" if setup_mode else "HomeNet", + "pwd": "" if setup_mode else "secretpw", # stored raw; masked on GET + "powersave": "min", + }, + "mqtt": { + "origin": "" if setup_mode else "MockNode", "iata": "" if setup_mode else "DEN", + "status": True, "packets": True, "raw": False, "tx": "advert", "rx": True, + "interval": 5, "timezone": "MST7MDT,M3.2.0,M11.1.0", "timezone_offset": -7, + "ntp": "pool.ntp.org", "owner": "", "email": "", "snmp": False, + "snmp_community": "public", + "slots": [_slot() for _ in range(6)], + }, + } + + +def _slot(): + return {"preset": "none", "server": "", "port": 8883, "username": "", + "password": "", "token": "", "topic": "", "audience": ""} + + +class State: + def __init__(self, args): + self.lock = threading.Lock() + self.setup_mode = args.setup + self.active_slots = args.active_slots + self.cfg = default_config(args.setup) + self.start = time.time() + self.session = None # cookie token when logged in (LAN mode) + self.batch = {"state": "idle"} + self.scan_started = None + + # ---- auth ------------------------------------------------------------- + def is_authed(self, headers): + if self.setup_mode: + return True # setup mode: proximity trust, no auth + if not self.session: + return False + cookie = headers.get("Cookie", "") + m = re.search(r"wcs=([0-9a-f]+)", cookie) + return bool(m and m.group(1) == self.session) + + # ---- config serialization (masks secrets, like handleConfigGet) ------- + def config_json(self): + c = copy.deepcopy(self.cfg) + c["wifi"]["pwd"] = SENTINEL if self.cfg["wifi"]["pwd"] else "" + for s in c["mqtt"]["slots"]: + s["password"] = SENTINEL if s["password"] else "" + s["token"] = SENTINEL if s["token"] else "" + return c + + def status_json(self, authed): + return { + "mode": "setup" if self.setup_mode else "lan", + "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)", + "uptime_s": int(time.time() - self.start), + "runtime_slots": 6, "max_slots": 6, "active_slots": self.active_slots, + } + + +# --------------------------------------------------------------------------- +# set-command application + validation (mirrors the firmware's setters enough +# to produce realistic per-field OK / Error replies for the UI chips). +# --------------------------------------------------------------------------- +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")} +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"), + "timezone.offset": ("mqtt", "timezone_offset")} +FLOAT_KEYS = {"lat": ("radio", "lat"), "lon": ("radio", "lon"), + "af": ("radio", "af"), "rxdelay": ("radio", "rxdelay"), + "txdelay": ("radio", "txdelay")} +STR_KEYS = {"name": ("radio", "name"), "wifi.ssid": ("wifi", "ssid"), + "wifi.powersave": ("wifi", "powersave"), "loop.detect": ("radio", "loop_detect"), + "mqtt.origin": ("mqtt", "origin"), "mqtt.ntp": ("mqtt", "ntp"), + "mqtt.email": ("mqtt", "email"), "timezone": ("mqtt", "timezone"), + "snmp.community": ("mqtt", "snmp_community"), "mqtt.tx": ("mqtt", "tx")} +SECRET_STR_KEYS = {"wifi.pwd": ("wifi", "pwd")} + + +def _hex64(v): + return len(v) == 64 and all(c in "0123456789abcdefABCDEF" for c in v) + + +def apply_set(cfg, key, val): + """Return (ok, reply) and mutate cfg. Mirrors the firmware's validation for + the fields where it matters (length, IATA, owner key, port, radio combo).""" + # length guard for the plain string fields + if key in LEN_LIMITS and len(val) > LEN_LIMITS[key]: + return False, "Error: %s too long (max %d chars)" % (key, LEN_LIMITS[key]) + + if key == "radio": + try: + f, bw, sf, cr = val.split(",") + f, bw, sf, cr = float(f), float(bw), int(sf), int(cr) + except ValueError: + return False, "Error, invalid radio params" + if not (150 <= f <= 2500 and 7 <= bw <= 500 and 5 <= sf <= 12 and 5 <= cr <= 8): + return False, "Error, invalid radio params" + cfg["radio"].update(freq=f, bw=bw, sf=sf, cr=cr) + return True, "OK - reboot to apply" + + if key == "mqtt.iata": + if val == "": + cfg["mqtt"]["iata"] = "" + return True, "OK - IATA cleared" + if len(val) != 3 or not val.isalnum() or not val.isascii(): + return False, "Error: IATA code must be exactly 3 letters/digits (e.g. DEN)" + cfg["mqtt"]["iata"] = val.upper() + return True, "OK" + + if key == "mqtt.owner": + if val == "": + cfg["mqtt"]["owner"] = "" + return True, "OK - owner key cleared" + if not _hex64(val): + return False, "Error: public key must be 64 hex characters (32 bytes)" + cfg["mqtt"]["owner"] = val + return True, "OK" + + m = re.match(r"^mqtt([1-6])\.(\w+)$", key) + if m: + return apply_slot_set(cfg, int(m.group(1)) - 1, m.group(2), val) + + if key in BOOL_KEYS: + sec, f = BOOL_KEYS[key] + cfg[sec][f] = (val == "on") + return True, "OK" + if key in INT_KEYS: + sec, f = INT_KEYS[key] + try: + cfg[sec][f] = int(val) + except ValueError: + return False, "Error: expected a number" + return True, "OK" + if key in FLOAT_KEYS: + sec, f = FLOAT_KEYS[key] + try: + cfg[sec][f] = float(val) + except ValueError: + return False, "Error: expected a number" + return True, "OK" + if key in SECRET_STR_KEYS: + sec, f = SECRET_STR_KEYS[key] + cfg[sec][f] = val + return True, "OK" + if key in STR_KEYS: + sec, f = STR_KEYS[key] + cfg[sec][f] = val + return True, "OK" + return True, "OK" # unknown-but-allowlisted: accept (mock is lenient here) + + +def apply_slot_set(cfg, idx, field, val): + slot = cfg["mqtt"]["slots"][idx] + if field in SLOT_LEN_LIMITS and len(val) > SLOT_LEN_LIMITS[field]: + return False, "Error: %s too long (max %d chars)" % (field, SLOT_LEN_LIMITS[field]) + if field == "port": + try: + p = int(val) + except ValueError: + return False, "Error: port must be between 1 and 65535" + if not (1 <= p <= 65535): + return False, "Error: port must be between 1 and 65535" + slot["port"] = p + return True, "OK" + if field in ("preset", "server", "username", "password", "token", "topic", "audience"): + slot[field] = val + if field == "token": + return True, "OK - slot %d token set" % (idx + 1) + return True, "OK" + return False, "Error: unknown slot field" + + +def is_secret_key(key): + return key == "wifi.pwd" or bool(re.match(r"^mqtt[1-6]\.(password|token)$", key)) + + +# --------------------------------------------------------------------------- +# HTTP handler +# --------------------------------------------------------------------------- +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *args): # concise one-line log + print(" %s %s" % (self.command, self.path)) + + # -- helpers -- + def _json(self, code, obj, extra_headers=None): + body = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + for k, v in (extra_headers or {}): + self.send_header(k, v) + self.end_headers() + self.wfile.write(body) + + def _read_body(self): + n = int(self.headers.get("Content-Length", 0)) + return self.rfile.read(n) if n else b"" + + def _need_auth(self): + if not ST.is_authed(self.headers): + self._json(401, {"error": "auth"}) + return True + return False + + # -- GET -- + def do_GET(self): + path = self.path.split("?", 1)[0] + if path == "/": + return self._serve_index() + if path == "/api/status": + return self._json(200, ST.status_json(ST.is_authed(self.headers))) + if path == "/api/presets": + return self._json(200, {"presets": [{"name": n, "needs": nd} for n, nd in PRESETS]}) + if path == "/api/config": + if self._need_auth(): + return + with ST.lock: + return self._json(200, ST.config_json()) + if path == "/api/config/result": + if self._need_auth(): + return + return self._config_result() + if path == "/api/stats": + if self._need_auth(): + return + return self._json(200, self._stats()) + if path == "/api/scan": + if self._need_auth(): + return + return self._scan() + return self._json(404, {"error": "not found"}) + + # -- POST -- + def do_POST(self): + path = self.path.split("?", 1)[0] + if path == "/api/login": + return self._login() + if path == "/api/logout": + ST.session = None + return self._json(200, {"ok": True}, [("Set-Cookie", "wcs=; Max-Age=0; Path=/")]) + if path == "/api/config": + if self._need_auth(): + return + return self._config_post() + if path == "/api/reboot": + if self._need_auth(): + return + return self._json(200, {"ok": True}) + if path == "/api/portal/exit": + return self._json(200, {"ok": True, "url": "http://localhost:%d/" % PORT}) + return self._json(404, {"error": "not found"}) + + # -- endpoint impls -- + def _serve_index(self): + try: + with open(INDEX_HTML, "rb") as f: # re-read each time -> live edits + html = f.read() + except OSError: + self.send_error(500, "webui/index.html not found") + return + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(html))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(html) + + def _login(self): + if ST.setup_mode: + return self._json(200, {"ok": True}) + try: + body = json.loads(self._read_body() or b"{}") + except ValueError: + return self._json(400, {"error": "bad request"}) + if body.get("password") != ADMIN_PASSWORD: + return self._json(401, {"error": "wrong password"}) + ST.session = secrets.token_hex(16) + return self._json(200, {"ok": True}, + [("Set-Cookie", "wcs=%s; HttpOnly; SameSite=Lax; Path=/" % ST.session)]) + + def _config_post(self): + raw = self._read_body() + if len(raw) > 4096: + 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", "") + reboot = bool(body.get("reboot", False)) + setmap = body.get("set", {}) or {} + + with ST.lock: + if ST.batch.get("state") == "pending": + return self._json(409, {"error": "busy", "reqid": ST.batch.get("reqid", "")}) + # drop unchanged secrets (sentinel), like the firmware does + entries = [(k, v) for k, v in setmap.items() + if not (is_secret_key(k) and v == SENTINEL)] + if not entries and not reboot: + return self._json(400, {"error": "no changes"}) + # apply now, but expose as pending->done to exercise polling + results, all_ok = [], True + for k, v in entries: + ok, reply = apply_set(ST.cfg, k, str(v)) + if not ok: + all_ok = False + results.append({"key": k, "reply": reply}) + ST.batch = {"state": "pending", "reqid": reqid, "results": results, + "all_ok": all_ok, "reboot": reboot, + "done_at": time.time() + BATCH_PENDING_SECS} + return self._json(202, {"state": "pending", "count": len(entries), "reqid": reqid}) + + def _config_result(self): + with ST.lock: + b = ST.batch + if b.get("state") == "idle": + return self._json(200, {"state": "idle"}) + if b["state"] == "pending" and time.time() < b["done_at"]: + return self._json(200, {"state": "pending", "reqid": b["reqid"]}) + b["state"] = "done" # stays readable until next POST + return self._json(200, { + "state": "done", "reqid": b["reqid"], "all_ok": b["all_ok"], + "reboot": b["reboot"] and b["all_ok"], "results": b["results"], + }) + + def _scan(self): + rescan = "rescan=1" in self.path + now = time.time() + if rescan or ST.scan_started is None: + ST.scan_started = now + return self._json(200, {"state": "scanning"}) + if now - ST.scan_started < SCAN_SECS: + return self._json(200, {"state": "scanning"}) + return self._json(200, {"state": "done", "networks": SCAN_NETWORKS}) + + def _stats(self): + up = int(time.time() - ST.start) + slots = [] + for i, s in enumerate(ST.cfg["mqtt"]["slots"]): + if s["preset"] == "none": + continue + slots.append({"n": i + 1, "name": s["preset"], "state": "ok", + "ok": 100 + up, "err": 0}) + return { + "uptime_s": up, "batt_mv": 4020, "heap_free": 142000, "heap_min": 118000, + "heap_max_alloc": 96000, "noise": -98, "rssi": -71, "snr": 9.5, + "airtime_s": up // 20, "rx_airtime_s": up // 8, "recv": 512 + up, + "sent": 88 + up // 3, "rx_err": 3, "sent_flood": 40, "sent_direct": 48, + "recv_flood": 300, "recv_direct": 212, "tx_queue": 0, "mqtt_queue": 0, + "wifi_rssi": -58, "ip": "192.168.1.42", "slots": slots, + } + + +def main(): + global ST, PORT + 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)") + args = ap.parse_args() + ST, PORT = State(args), args.port + + 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(" open http://localhost:%d/ (Ctrl-C to stop)" % args.port) + try: + srv.serve_forever() + except KeyboardInterrupt: + print("\nstopped") + + +if __name__ == "__main__": + main() diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index ab724717..4023413b 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -12,6 +12,7 @@ #include "CommonCLI.h" #include "TxtDataHelpers.h" #include "AlertReporter.h" // for alertReporterBannedChannelMatch[Hex]() +#include "MQTTObserverValidation.h" // pure input validators (host-testable) #include #ifdef ESP_PLATFORM #include @@ -92,19 +93,16 @@ static int getMQTTPresetNameCount() { return MQTT_PRESET_COUNT + 2; // built-ins + custom + none } -static bool isValidNtpHostname(const char* host) { - if (!host || host[0] == '\0') return false; - size_t len = strlen(host); - if (len > 63) return false; - if (host[0] == '.' || host[len - 1] == '.') return false; - for (size_t i = 0; i < len; i++) { - char c = host[i]; - if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || - (c >= '0' && c <= '9') || c == '.' || c == '-')) { - return false; - } +// Reject a value that wouldn't fit its destination MQTTPrefs buffer (which must +// hold the string plus a NUL) so an over-long CLI/web submission fails loudly +// instead of being silently truncated. Fills reply and returns true when too +// long. reply is the caller's 160-byte command buffer. +static bool valueTooLong(const char* val, size_t bufsize, char* reply, const char* label) { + if (!mqttValueFits(val, bufsize)) { + snprintf(reply, 160, "Error: %s too long (max %u chars)", label, (unsigned)(bufsize - 1)); + return true; } - return true; + return false; } static const char* getMQTTPresetNameByIndex(int index) { @@ -163,6 +161,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf #ifdef WITH_MQTT_BRIDGE bool handled = true; if (memcmp(config, "snmp.community ", 15) == 0) { + if (valueTooLong(&config[15], sizeof(_mqtt_prefs.snmp_community), reply, "snmp.community")) return true; StrHelper::strncpy(_mqtt_prefs.snmp_community, &config[15], sizeof(_mqtt_prefs.snmp_community)); savePrefs(); strcpy(reply, "OK - restart to apply"); @@ -200,6 +199,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf savePrefs(); strcpy(reply, "OK"); } else if (memcmp(config, "mqtt.origin ", 12) == 0) { + if (valueTooLong(&config[12], sizeof(_mqtt_prefs.mqtt_origin), reply, "origin")) return true; StrHelper::strncpy(_mqtt_prefs.mqtt_origin, &config[12], sizeof(_mqtt_prefs.mqtt_origin)); StrHelper::stripSurroundingQuotes(_mqtt_prefs.mqtt_origin, sizeof(_mqtt_prefs.mqtt_origin)); savePrefs(); @@ -217,12 +217,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf } else { // A region code goes straight into MQTT topic paths, so require exactly // three alphanumeric characters (real IATA codes are 3 letters, e.g. DEN). - bool valid = (iata_len == 3); - for (size_t i = 0; valid && i < iata_len; i++) { - char c = iata[i]; - valid = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'); - } - if (!valid) { + if (!mqttIataValid(iata)) { strcpy(reply, "Error: IATA code must be exactly 3 letters/digits (e.g. DEN)"); } else { StrHelper::strncpy(_mqtt_prefs.mqtt_iata, iata, sizeof(_mqtt_prefs.mqtt_iata)); @@ -272,7 +267,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf const char* host = &config[9]; while (*host == ' ') host++; bool clearing = strcmp(host, "none") == 0; - if (!clearing && !isValidNtpHostname(host)) { + if (!clearing && !mqttNtpHostnameValid(host)) { strcpy(reply, "Error: invalid NTP hostname"); } else { if (clearing) { @@ -300,10 +295,12 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf #endif } } else if (memcmp(config, "wifi.ssid ", 10) == 0) { + if (valueTooLong(&config[10], sizeof(_mqtt_prefs.wifi_ssid), reply, "wifi.ssid")) return true; StrHelper::strncpy(_mqtt_prefs.wifi_ssid, &config[10], sizeof(_mqtt_prefs.wifi_ssid)); savePrefs(); strcpy(reply, "OK"); } else if (memcmp(config, "wifi.pwd ", 9) == 0) { + if (valueTooLong(&config[9], sizeof(_mqtt_prefs.wifi_password), reply, "wifi.pwd")) return true; StrHelper::strncpy(_mqtt_prefs.wifi_password, &config[9], sizeof(_mqtt_prefs.wifi_password)); savePrefs(); strcpy(reply, "OK"); @@ -347,6 +344,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf #endif } } else if (memcmp(config, "timezone ", 9) == 0) { + if (valueTooLong(&config[9], sizeof(_mqtt_prefs.timezone_string), reply, "timezone")) return true; StrHelper::strncpy(_mqtt_prefs.timezone_string, &config[9], sizeof(_mqtt_prefs.timezone_string)); savePrefs(); strcpy(reply, "OK"); @@ -407,6 +405,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf strcpy(reply, "Error: unknown preset. Use 'get mqtt.presets'"); } } else if (memcmp(subcmd, "server ", 7) == 0) { + if (valueTooLong(&subcmd[7], sizeof(_mqtt_prefs.mqtt_slot_host[slot]), reply, "server")) return true; StrHelper::strncpy(_mqtt_prefs.mqtt_slot_host[slot], &subcmd[7], sizeof(_mqtt_prefs.mqtt_slot_host[slot])); savePrefs(); // Reconfigure the slot so the new host reaches the live connection (other @@ -425,16 +424,19 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf strcpy(reply, "Error: port must be between 1 and 65535"); } } else if (memcmp(subcmd, "username ", 9) == 0) { + if (valueTooLong(&subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_username[slot]), reply, "username")) return true; StrHelper::strncpy(_mqtt_prefs.mqtt_slot_username[slot], &subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_username[slot])); savePrefs(); _callbacks->restartBridgeSlot(slot); strcpy(reply, "OK"); } else if (memcmp(subcmd, "password ", 9) == 0) { + if (valueTooLong(&subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_password[slot]), reply, "password")) return true; StrHelper::strncpy(_mqtt_prefs.mqtt_slot_password[slot], &subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_password[slot])); savePrefs(); _callbacks->restartBridgeSlot(slot); strcpy(reply, "OK"); } else if (memcmp(subcmd, "token ", 6) == 0) { + if (valueTooLong(&subcmd[6], sizeof(_mqtt_prefs.mqtt_slot_token[slot]), reply, "token")) return true; StrHelper::strncpy(_mqtt_prefs.mqtt_slot_token[slot], &subcmd[6], sizeof(_mqtt_prefs.mqtt_slot_token[slot])); savePrefs(); _callbacks->restartBridgeSlot(slot); @@ -442,6 +444,8 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf } else if (memcmp(subcmd, "topic ", 6) == 0) { if (strcmp(_mqtt_prefs.mqtt_slot_preset[slot], "custom") != 0) { sprintf(reply, "Error: topic template only applies to custom preset slots"); + } else if (valueTooLong(&subcmd[6], sizeof(_mqtt_prefs.mqtt_slot_topic[slot]), reply, "topic")) { + return true; } else { StrHelper::strncpy(_mqtt_prefs.mqtt_slot_topic[slot], &subcmd[6], sizeof(_mqtt_prefs.mqtt_slot_topic[slot])); savePrefs(); @@ -449,6 +453,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf sprintf(reply, "OK - slot %d topic: %s", slot + 1, _mqtt_prefs.mqtt_slot_topic[slot]); } } else if (memcmp(subcmd, "audience ", 9) == 0) { + if (valueTooLong(&subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_audience[slot]), reply, "audience")) return true; StrHelper::strncpy(_mqtt_prefs.mqtt_slot_audience[slot], &subcmd[9], sizeof(_mqtt_prefs.mqtt_slot_audience[slot])); savePrefs(); _callbacks->restartBridgeSlot(slot); @@ -488,34 +493,21 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf strcpy(reply, "OK"); } else if (memcmp(config, "mqtt.owner ", 11) == 0) { const char* owner_key = &config[11]; - int key_len = strlen(owner_key); - if (key_len == 0) { + if (owner_key[0] == '\0') { // Owner key is optional — empty clears it (previously this errored, so a // set key could never be removed via the portal/CLI). _mqtt_prefs.mqtt_owner_public_key[0] = '\0'; savePrefs(); strcpy(reply, "OK - owner key cleared"); - } else if (key_len == 64) { - bool valid_key = true; - for (int i = 0; i < key_len; i++) { - if (!((owner_key[i] >= '0' && owner_key[i] <= '9') || - (owner_key[i] >= 'A' && owner_key[i] <= 'F') || - (owner_key[i] >= 'a' && owner_key[i] <= 'f'))) { - valid_key = false; - break; - } - } - if (valid_key) { - StrHelper::strncpy(_mqtt_prefs.mqtt_owner_public_key, owner_key, sizeof(_mqtt_prefs.mqtt_owner_public_key)); - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error: invalid hex characters in public key"); - } + } else if (mqttOwnerKeyValid(owner_key)) { + StrHelper::strncpy(_mqtt_prefs.mqtt_owner_public_key, owner_key, sizeof(_mqtt_prefs.mqtt_owner_public_key)); + savePrefs(); + strcpy(reply, "OK"); } else { strcpy(reply, "Error: public key must be 64 hex characters (32 bytes)"); } } else if (memcmp(config, "mqtt.email ", 11) == 0) { + if (valueTooLong(&config[11], sizeof(_mqtt_prefs.mqtt_email), reply, "email")) return true; StrHelper::strncpy(_mqtt_prefs.mqtt_email, &config[11], sizeof(_mqtt_prefs.mqtt_email)); savePrefs(); strcpy(reply, "OK"); diff --git a/src/helpers/MQTTObserverValidation.h b/src/helpers/MQTTObserverValidation.h new file mode 100644 index 00000000..2042f226 --- /dev/null +++ b/src/helpers/MQTTObserverValidation.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include + +// Pure, dependency-free validators for the observer's CLI/web configuration +// inputs. Factored out of CommonCLI_Observer.cpp so the exact logic the setters +// enforce can be unit-tested on the host (see test/test_observer_validation) +// rather than only through the full CLI object. + +// IATA region code: exactly three ASCII alphanumerics. The value is placed +// directly into MQTT topic paths (meshcore/{iata}/...), so anything else (wrong +// length, spaces, topic separators) is rejected. Case is preserved here; the +// setter uppercases after validation. +static inline bool mqttIataValid(const char* s) { + if (!s || strlen(s) != 3) return false; + for (int i = 0; i < 3; i++) { + char c = s[i]; + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'))) { + return false; + } + } + return true; +} + +// Owner public key: exactly 64 hex characters (a 32-byte Ed25519 key), any case. +static inline bool mqttOwnerKeyValid(const char* s) { + if (!s || strlen(s) != 64) return false; + for (int i = 0; i < 64; i++) { + char c = s[i]; + if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) { + return false; + } + } + return true; +} + +// NTP hostname: non-empty, <= 63 chars, made of letters/digits/'.'/'-', with no +// leading or trailing dot. ("none" is handled as a clear by the caller.) +static inline bool mqttNtpHostnameValid(const char* host) { + if (!host || host[0] == '\0') return false; + size_t len = strlen(host); + if (len > 63) return false; + if (host[0] == '.' || host[len - 1] == '.') return false; + for (size_t i = 0; i < len; i++) { + char c = host[i]; + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '.' || c == '-')) { + return false; + } + } + return true; +} + +// A value fits its fixed destination buffer, which must hold the string plus a +// NUL terminator (so the usable length is bufsize - 1). Used to reject an +// over-long submission up front instead of silently truncating it. +static inline bool mqttValueFits(const char* s, size_t bufsize) { + return s != NULL && bufsize > 0 && strlen(s) < bufsize; +} diff --git a/src/helpers/MQTTPresets.h b/src/helpers/MQTTPresets.h index 8ab063e0..be43773a 100644 --- a/src/helpers/MQTTPresets.h +++ b/src/helpers/MQTTPresets.h @@ -1,5 +1,8 @@ #pragma once +#include +#include // strcmp/memcmp used by the inline preset helpers below + // Maximum number of configurable MQTT connection slots (available to all builds for struct layout). // Used in NodePrefs/MQTTPrefs for persistent storage — do NOT change without migration. static const int MAX_MQTT_SLOTS = 6; diff --git a/src/helpers/MQTTTopicTemplate.h b/src/helpers/MQTTTopicTemplate.h new file mode 100644 index 00000000..7f50abf9 --- /dev/null +++ b/src/helpers/MQTTTopicTemplate.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +// Expand the {iata} {device} {token} {type} placeholders in a custom MQTT topic +// template. Factored out of MQTTBridge::substituteTopicTemplate so the (bounded) +// string expansion can be unit-tested on the host; the bridge passes its cached +// _iata / _device_id, the slot token, and the message-type string. +// +// Returns false on buffer overflow or an empty result. buf is always +// NUL-terminated. A null value substitutes as empty; an unknown "{...}" token is +// copied through verbatim. +static inline bool mqttSubstituteTopic(const char* tmpl, const char* iata, + const char* device, const char* token, + const char* type_str, char* buf, size_t buf_size) { + if (!buf || buf_size == 0) return false; + if (!iata) iata = ""; + if (!device) device = ""; + if (!token) token = ""; + if (!type_str) type_str = ""; + + size_t out = 0; + const char* p = tmpl ? tmpl : ""; + while (*p && out < buf_size - 1) { + const char* sub = NULL; + size_t adv = 0; + if (strncmp(p, "{iata}", 6) == 0) { + sub = iata; adv = 6; + } else if (strncmp(p, "{device}", 8) == 0) { + sub = device; adv = 8; + } else if (strncmp(p, "{token}", 7) == 0) { + sub = token; adv = 7; + } else if (strncmp(p, "{type}", 6) == 0) { + sub = type_str; adv = 6; + } + if (sub) { + size_t len = strlen(sub); + if (out + len >= buf_size) { + buf[out] = '\0'; // keep buf terminated even on the overflow path + return false; + } + memcpy(buf + out, sub, len); + out += len; + p += adv; + } else { + buf[out++] = *p++; + } + } + buf[out] = '\0'; + return out > 0; +} diff --git a/src/helpers/WebConfigKeys.h b/src/helpers/WebConfigKeys.h new file mode 100644 index 00000000..f382309b --- /dev/null +++ b/src/helpers/WebConfigKeys.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include "MQTTPresets.h" // MAX_MQTT_SLOTS + +// Classification of the config keys the web portal is allowed to drive through +// the CLI `set` handlers. Factored out of WebConfigServer.cpp so the allowlist +// and the (attacker-facing) key parsing can be unit-tested on the host without +// pulling in the whole ESP32 web server (see test/test_webconfig_keys). +// +// Everything here is pure string logic. The functions are `static inline` so +// each translation unit that includes this gets its own copy (there are only +// two: WebConfigServer.cpp and the test), avoiding any ODR concern. + +// Keys mapping to CLI `set ` handlers. Everything not listed here +// is rejected, so a crafted request can't reach arbitrary commands (`erase`, +// `password`, ...) through the batch. +static const char* const WC_ALLOWED_SET_KEYS[] = { + // NodePrefs (radio / node) + "name", "lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay", + "cad", "radio.rxgain", "repeat", "advert.interval", "flood.advert.interval", + "flood.max", "flood.max.advert", "flood.max.unscoped", "loop.detect", + // MQTTPrefs (WiFi / MQTT / misc observer) + "wifi.ssid", "wifi.pwd", "wifi.powersave", + "mqtt.origin", "mqtt.iata", "mqtt.status", "mqtt.packets", "mqtt.raw", + "mqtt.tx", "mqtt.rx", "mqtt.interval", "mqtt.ntp", "mqtt.owner", "mqtt.email", + "timezone", "timezone.offset", "snmp", "snmp.community", +}; +static const char* const WC_ALLOWED_SLOT_KEYS[] = { + "preset", "server", "port", "username", "password", "token", "topic", "audience", +}; + +// True when `key` is a well-formed per-slot key ("mqttN." with N in +// 1..MAX_MQTT_SLOTS). The shortest such key is "mqttN.x" (7 chars), and this +// probes key[4..6], so the length guard must come first — an attacker-supplied +// "mqtt" or "m" would otherwise read past the terminator. +static inline bool wcIsSlotKeyPrefix(const char* key) { + return strlen(key) >= 7 && memcmp(key, "mqtt", 4) == 0 + && key[4] >= '1' && key[4] <= ('0' + MAX_MQTT_SLOTS) && key[5] == '.'; +} + +static inline bool wcIsAllowedSetKey(const char* key) { + for (size_t i = 0; i < sizeof(WC_ALLOWED_SET_KEYS) / sizeof(WC_ALLOWED_SET_KEYS[0]); i++) { + if (strcmp(key, WC_ALLOWED_SET_KEYS[i]) == 0) return true; + } + // mqtt<1-6>. + if (wcIsSlotKeyPrefix(key)) { + for (size_t i = 0; i < sizeof(WC_ALLOWED_SLOT_KEYS) / sizeof(WC_ALLOWED_SLOT_KEYS[0]); i++) { + if (strcmp(&key[6], WC_ALLOWED_SLOT_KEYS[i]) == 0) return true; + } + } + return false; +} + +// Keys carrying a secret whose stored value is masked with the placeholder in +// the UI; a POST echoing the placeholder for one of these is dropped (unchanged). +static inline bool wcIsSecretKey(const char* key) { + if (strcmp(key, "wifi.pwd") == 0) return true; + if (wcIsSlotKeyPrefix(key) + && (strcmp(&key[6], "password") == 0 || strcmp(&key[6], "token") == 0)) return true; + return false; +} diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 0bed6211..9599d2f2 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -1,5 +1,6 @@ #include "MQTTBridge.h" #include "../MQTTMessageBuilder.h" +#include "../MQTTTopicTemplate.h" #include "../TxtDataHelpers.h" #include #include @@ -1885,45 +1886,9 @@ bool MQTTBridge::publishToAllSlots(const char* topic, const char* payload, bool // --------------------------------------------------------------------------- bool MQTTBridge::substituteTopicTemplate(const char* tmpl, MQTTMessageType type, int slot_index, char* buf, size_t buf_size) { const char* type_str = (type == MSG_STATUS) ? "status" : (type == MSG_PACKETS) ? "packets" : "raw"; - const char* token = _obs->mqtt_slot_token[slot_index]; - - size_t out = 0; - const char* p = tmpl; - while (*p && out < buf_size - 1) { - if (*p == '{') { - if (strncmp(p, "{iata}", 6) == 0) { - size_t len = strlen(_iata); - if (out + len >= buf_size) return false; - memcpy(buf + out, _iata, len); - out += len; - p += 6; - } else if (strncmp(p, "{device}", 8) == 0) { - size_t len = strlen(_device_id); - if (out + len >= buf_size) return false; - memcpy(buf + out, _device_id, len); - out += len; - p += 8; - } else if (strncmp(p, "{token}", 7) == 0) { - size_t len = strlen(token); - if (out + len >= buf_size) return false; - memcpy(buf + out, token, len); - out += len; - p += 7; - } else if (strncmp(p, "{type}", 6) == 0) { - size_t len = strlen(type_str); - if (out + len >= buf_size) return false; - memcpy(buf + out, type_str, len); - out += len; - p += 6; - } else { - buf[out++] = *p++; - } - } else { - buf[out++] = *p++; - } - } - buf[out] = '\0'; - return out > 0; + // Pure expansion lives in helpers/MQTTTopicTemplate.h (host-tested). + return mqttSubstituteTopic(tmpl, _iata, _device_id, + _obs->mqtt_slot_token[slot_index], type_str, buf, buf_size); } bool MQTTBridge::buildTopicForSlot(int index, MQTTMessageType type, char* topic_buf, size_t buf_size) { diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 73be678f..e8e7f9ba 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include "WebConfigHtml.h" @@ -20,52 +21,11 @@ // so an untouched password field never overwrites the stored value. static const char SECRET_SENTINEL[] = "********"; -// Keys the web UI may drive through the CLI `set` handlers. Everything else -// is rejected, so a crafted request can't reach arbitrary commands (`erase`, -// `password`, ...) through the batch. -static const char* const ALLOWED_SET_KEYS[] = { - // NodePrefs (radio / node) - "name", "lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay", - "cad", "radio.rxgain", "repeat", "advert.interval", "flood.advert.interval", - "flood.max", "flood.max.advert", "flood.max.unscoped", "loop.detect", - // MQTTPrefs (WiFi / MQTT / misc observer) - "wifi.ssid", "wifi.pwd", "wifi.powersave", - "mqtt.origin", "mqtt.iata", "mqtt.status", "mqtt.packets", "mqtt.raw", - "mqtt.tx", "mqtt.rx", "mqtt.interval", "mqtt.ntp", "mqtt.owner", "mqtt.email", - "timezone", "timezone.offset", "snmp", "snmp.community", -}; -static const char* const ALLOWED_SLOT_KEYS[] = { - "preset", "server", "port", "username", "password", "token", "topic", "audience", -}; - -// Shortest slot key is "mqttN.x" (mqtt + digit + '.' + 1-char field) = 7 chars. -// The prefix probe below indexes key[4..6], so it must never run on a shorter -// string — an attacker-supplied "mqtt" or "m" would otherwise read past the -// terminator. strcmp() is null-safe, so the exact-match loop needs no guard. -static bool isSlotKeyPrefix(const char* key) { - return strlen(key) >= 7 && memcmp(key, "mqtt", 4) == 0 - && key[4] >= '1' && key[4] <= ('0' + MAX_MQTT_SLOTS) && key[5] == '.'; -} - -static bool isAllowedSetKey(const char* key) { - for (size_t i = 0; i < sizeof(ALLOWED_SET_KEYS) / sizeof(ALLOWED_SET_KEYS[0]); i++) { - if (strcmp(key, ALLOWED_SET_KEYS[i]) == 0) return true; - } - // mqtt<1-6>. - if (isSlotKeyPrefix(key)) { - for (size_t i = 0; i < sizeof(ALLOWED_SLOT_KEYS) / sizeof(ALLOWED_SLOT_KEYS[0]); i++) { - if (strcmp(&key[6], ALLOWED_SLOT_KEYS[i]) == 0) return true; - } - } - return false; -} - -static bool isSecretKey(const char* key) { - if (strcmp(key, "wifi.pwd") == 0) return true; - if (isSlotKeyPrefix(key) - && (strcmp(&key[6], "password") == 0 || strcmp(&key[6], "token") == 0)) return true; - return false; -} +// Key classification (allowlist, secret detection, slot-prefix parsing) lives in +// helpers/WebConfigKeys.h so it can be unit-tested on the host. Thin aliases keep +// the call sites below readable. +static inline bool isAllowedSetKey(const char* key) { return wcIsAllowedSetKey(key); } +static inline bool isSecretKey(const char* key) { return wcIsSecretKey(key); } // 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) { diff --git a/src/helpers/sim/SimRadio.h b/src/helpers/sim/SimRadio.h new file mode 100644 index 00000000..2e7d6254 --- /dev/null +++ b/src/helpers/sim/SimRadio.h @@ -0,0 +1,75 @@ +#pragma once + +// A no-hardware stand-in for the LoRa radio, so observer firmware can boot and +// run in an emulator (e.g. Wokwi) that models the ESP32-S3 + WiFi + display but +// has no SX1262. It's a drop-in for the concrete `radio_driver` used by the +// examples: it implements the mesh::Radio interface plus the RadioLibWrapper +// methods MyMesh/main call directly (setParams, setTxPower, getRngSeed, packet +// counters, …). Transmits "succeed" instantly with no RF; nothing is ever +// received. WiFi/MQTT/CLI/display all run normally on top of it. +// +// Compiled only into *_sim builds (guarded by SIM_BUILD in the target). Never +// pulled into real firmware. + +#include +#include +#include +#if defined(ESP_PLATFORM) + #include // esp_random() +#endif + +static inline uint32_t _simRandom() { +#if defined(ESP_PLATFORM) + return esp_random(); +#else + return (uint32_t)millis() * 2654435761u; +#endif +} + +// RNG for creating a LocalIdentity without radio noise (used by radio_new_identity()). +class SimRNG : public mesh::RNG { +public: + void random(uint8_t* dest, size_t sz) override { + for (size_t i = 0; i < sz; i++) dest[i] = (uint8_t)_simRandom(); + } +}; + +class SimRadio : public mesh::Radio { + uint32_t n_recv, n_sent, n_recv_errors; + unsigned long _send_started; +public: + explicit SimRadio(mesh::MainBoard& /*board*/) : n_recv(0), n_sent(0), + n_recv_errors(0), _send_started(0) {} + + // --- mesh::Radio pure virtuals --- + void begin() override {} + int recvRaw(uint8_t* /*bytes*/, int /*sz*/) override { return 0; } // never receives + uint32_t getEstAirtimeFor(int len_bytes) override { + return (uint32_t)(len_bytes < 0 ? 0 : len_bytes) * 10 + 10; // rough, non-zero + } + float packetScore(float /*snr*/, int /*packet_len*/) override { return 0.0f; } + bool startSendRaw(const uint8_t* /*bytes*/, int /*len*/) override { + _send_started = millis(); + n_sent++; + return true; // "sent" instantly + } + bool isSendComplete() override { return true; } + void onSendFinished() override { _send_started = 0; } + bool isInRecvMode() const override { return true; } + + // --- mesh::Radio overrides with useful sim values --- + int getNoiseFloor() const override { return -110; } + uint32_t getPacketsRecvErrors() const override { return n_recv_errors; } + float getLastRSSI() const override { return -80.0f; } + float getLastSNR() const override { return 9.0f; } + + // --- concrete RadioLibWrapper surface called directly on radio_driver --- + void setParams(float /*freq*/, float /*bw*/, uint8_t /*sf*/, uint8_t /*cr*/) {} + void setTxPower(int8_t /*dbm*/) {} + uint32_t getRngSeed() { return _simRandom(); } + uint32_t getPacketsRecv() const { return n_recv; } + uint32_t getPacketsSent() const { return n_sent; } + void resetStats() { n_recv = n_sent = n_recv_errors = 0; } + void setRxBoostedGainMode(bool) {} + bool getRxBoostedGainMode() const { return false; } +}; diff --git a/test/README.md b/test/README.md new file mode 100644 index 00000000..8379fc29 --- /dev/null +++ b/test/README.md @@ -0,0 +1,48 @@ +# Host unit tests + +Fast, hardware-free unit tests for the fork's pure logic, run on the host with +GoogleTest via PlatformIO's `native` environment. They cover the extractable +observer/WebConfig logic (validation, preset table, topic templates, key +parsing) — the parts that don't depend on the ESP32, radio, or network stack. +Integration behavior (AsyncTCP transport, WiFi/MQTT, SoftAP) is exercised +separately; see "Local testing without hardware" in `MQTT_IMPLEMENTATION.md`. + +## Running + +```sh +pio test -e native # all suites +pio test -e native -f test_webconfig_keys # a single suite +``` + +A green `[PASSED]` per suite means GoogleTest returned 0 (all assertions +passed). PlatformIO's "0 test cases" line is just its Unity-style counter and +does not reflect the GoogleTest count — run the built binary directly +(`.pio/build/native/program`) to see the per-assertion breakdown. + +## Suites + +| Suite | Source under test | Covers | +|-------|-------------------|--------| +| `test_mqtt_presets` | `src/helpers/MQTTPresets.h` | preset lookup; table integrity (unique names, non-empty URLs, JWT-audience invariant, names fit the slot buffer); `mqttPresetNeedsSlotCredentials`; slot-count constants | +| `test_observer_validation` | `src/helpers/MQTTObserverValidation.h` | IATA (exactly 3 alphanumerics), owner key (64 hex), NTP hostname, and the buffer-fit check behind the #17 length validation — including boundaries and nulls | +| `test_webconfig_keys` | `src/helpers/WebConfigKeys.h` | POST-key allowlist, secret detection, slot-index bounds, and the short-key out-of-bounds guard (attacker-supplied keys) | +| `test_topic_template` | `src/helpers/MQTTTopicTemplate.h` | `{iata}/{device}/{token}/{type}` expansion, overflow/NUL-termination, and a buffer-size fuzz | +| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) | + +## Conventions (and how to add a suite) + +- Each `test/test_/` directory builds into its **own** GoogleTest program + and must define its own `main()` (`::testing::InitGoogleTest` + `RUN_ALL_TESTS`). +- Tests are **host-only**: include only pure headers. Arduino/crypto stubs live + in `test/mocks/` (on the include path via `-I test/mocks`). +- Firmware headers are included from `src` (via `-I src`, e.g. + `#include "helpers/MQTTPresets.h"`). Some are guarded or ESP-flavored, so a + suite may need shims **before** the include — e.g. `test_mqtt_presets` does + `#define WITH_MQTT_BRIDGE 1` (the preset table is behind that flag) and + `#define PROGMEM` (the embedded CA-cert strings are PROGMEM-qualified). +- To add a suite: create `test/test_/test_.cpp` with a `main()`, and + add any host-only source it links to the `native` env's `build_src_filter` in + `platformio.ini` (header-only code needs no source entry). No other wiring. +- Keep logic testable by extracting pure functions into headers (as + `MQTTObserverValidation.h` / `WebConfigKeys.h` / `MQTTTopicTemplate.h` do) and + having the firmware call the same functions. diff --git a/test/test_mqtt_presets/test_mqtt_presets.cpp b/test/test_mqtt_presets/test_mqtt_presets.cpp new file mode 100644 index 00000000..78b5228d --- /dev/null +++ b/test/test_mqtt_presets/test_mqtt_presets.cpp @@ -0,0 +1,138 @@ +// Host tests for the MQTT observer preset table and lookup helpers +// (src/helpers/MQTTPresets.h). Pure logic — no ESP/radio dependencies. +// +// The preset table and lookup functions are compiled only for observer builds, +// so opt into that feature flag before including the header (the definitions are +// pure C++/data with no ESP dependencies). +#define WITH_MQTT_BRIDGE 1 +#define PROGMEM // host build: the CA-cert strings in MQTTPresets.h are PROGMEM-qualified +#include +#include +#include +#include +#include "helpers/MQTTPresets.h" + +// ---- findMQTTPreset ------------------------------------------------------- + +TEST(MQTTPresets, FindKnownPreset) { + const MQTTPresetDef* p = findMQTTPreset("analyzer-us"); + ASSERT_NE(nullptr, p); + EXPECT_STREQ("analyzer-us", p->name); + EXPECT_EQ(MQTT_AUTH_JWT, p->auth_type); + EXPECT_EQ(MQTT_TOPIC_MESHCORE, p->topic_style); +} + +TEST(MQTTPresets, FindReturnsTablePointer) { + // The returned pointer must be into the table, not a copy. + const MQTTPresetDef* p = findMQTTPreset("meshrank"); + ASSERT_NE(nullptr, p); + bool in_table = false; + for (int i = 0; i < MQTT_PRESET_COUNT; i++) { + if (p == &MQTT_PRESETS[i]) { in_table = true; break; } + } + EXPECT_TRUE(in_table); +} + +TEST(MQTTPresets, UnknownAndEmptyReturnNull) { + EXPECT_EQ(nullptr, findMQTTPreset("does-not-exist")); + EXPECT_EQ(nullptr, findMQTTPreset("")); + EXPECT_EQ(nullptr, findMQTTPreset(nullptr)); +} + +TEST(MQTTPresets, NoneAndCustomAreNotTablePresets) { + // "none"/"custom" are virtual presets handled by the CLI, not table entries. + EXPECT_EQ(nullptr, findMQTTPreset(MQTT_PRESET_NONE)); + EXPECT_EQ(nullptr, findMQTTPreset(MQTT_PRESET_CUSTOM)); + EXPECT_STREQ("none", MQTT_PRESET_NONE); + EXPECT_STREQ("custom", MQTT_PRESET_CUSTOM); +} + +TEST(MQTTPresets, LookupIsCaseSensitive) { + EXPECT_EQ(nullptr, findMQTTPreset("Analyzer-US")); +} + +// ---- table integrity ------------------------------------------------------ + +TEST(MQTTPresets, EveryNameIsUniqueAndNonEmpty) { + std::set names; + for (int i = 0; i < MQTT_PRESET_COUNT; i++) { + ASSERT_NE(nullptr, MQTT_PRESETS[i].name) << "preset " << i << " has null name"; + EXPECT_NE('\0', MQTT_PRESETS[i].name[0]) << "preset " << i << " has empty name"; + auto res = names.insert(MQTT_PRESETS[i].name); + EXPECT_TRUE(res.second) << "duplicate preset name: " << MQTT_PRESETS[i].name; + } + EXPECT_EQ((size_t)MQTT_PRESET_COUNT, names.size()); +} + +TEST(MQTTPresets, EveryPresetHasAServerUrl) { + for (int i = 0; i < MQTT_PRESET_COUNT; i++) { + ASSERT_NE(nullptr, MQTT_PRESETS[i].server_url) << MQTT_PRESETS[i].name; + EXPECT_NE('\0', MQTT_PRESETS[i].server_url[0]) << MQTT_PRESETS[i].name; + } +} + +TEST(MQTTPresets, JwtPresetsCarryAnAudience) { + // JWT auth needs an audience (the field doubles as the broker host here). + for (int i = 0; i < MQTT_PRESET_COUNT; i++) { + if (MQTT_PRESETS[i].auth_type == MQTT_AUTH_JWT) { + EXPECT_NE(nullptr, MQTT_PRESETS[i].jwt_audience) + << MQTT_PRESETS[i].name << " is JWT but has no audience"; + } + } +} + +TEST(MQTTPresets, NamesFitTheSlotPresetBuffer) { + // Stored preset name goes into mqtt_slot_preset[MAX][24]; keep < 24 chars. + for (int i = 0; i < MQTT_PRESET_COUNT; i++) { + EXPECT_LT(strlen(MQTT_PRESETS[i].name), (size_t)24) + << MQTT_PRESETS[i].name << " too long for slot-preset buffer"; + } +} + +// ---- mqttPresetNeedsSlotCredentials --------------------------------------- + +TEST(MQTTPresets, EmbeddedUserpassDoesNotNeedSlotCredentials) { + // tennmesh ships an embedded username+password. + const MQTTPresetDef* p = findMQTTPreset("tennmesh"); + ASSERT_NE(nullptr, p); + EXPECT_EQ(MQTT_AUTH_USERPASS, p->auth_type); + EXPECT_FALSE(mqttPresetNeedsSlotCredentials(p)); +} + +TEST(MQTTPresets, UserpassWithoutEmbeddedCredsNeedsSlotCredentials) { + // inwmesh is USERPASS with null user/pass -> must come from mqttN.username/password. + const MQTTPresetDef* p = findMQTTPreset("inwmesh"); + ASSERT_NE(nullptr, p); + EXPECT_EQ(MQTT_AUTH_USERPASS, p->auth_type); + EXPECT_TRUE(mqttPresetNeedsSlotCredentials(p)); +} + +TEST(MQTTPresets, NonUserpassNeverNeedsSlotCredentials) { + for (int i = 0; i < MQTT_PRESET_COUNT; i++) { + if (MQTT_PRESETS[i].auth_type != MQTT_AUTH_USERPASS) { + EXPECT_FALSE(mqttPresetNeedsSlotCredentials(&MQTT_PRESETS[i])) + << MQTT_PRESETS[i].name; + } + } + EXPECT_FALSE(mqttPresetNeedsSlotCredentials(nullptr)); +} + +TEST(MQTTPresets, MeshrankIsTokenStyleNoAuth) { + const MQTTPresetDef* p = findMQTTPreset("meshrank"); + ASSERT_NE(nullptr, p); + EXPECT_EQ(MQTT_TOPIC_MESHRANK, p->topic_style); + EXPECT_EQ(MQTT_AUTH_NONE, p->auth_type); +} + +// ---- slot count constants ------------------------------------------------- + +TEST(MQTTPresets, SlotCountsAreSane) { + EXPECT_GT(RUNTIME_MQTT_SLOTS, 0); + EXPECT_LE(RUNTIME_MQTT_SLOTS, MAX_MQTT_SLOTS); + EXPECT_EQ(6, MAX_MQTT_SLOTS); // persisted layout — must not drift without migration +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_observer_validation/test_observer_validation.cpp b/test/test_observer_validation/test_observer_validation.cpp new file mode 100644 index 00000000..3045caed --- /dev/null +++ b/test/test_observer_validation/test_observer_validation.cpp @@ -0,0 +1,135 @@ +// Host tests for the observer input validators shared by the CLI setters +// (src/helpers/MQTTObserverValidation.h): IATA, owner key, NTP hostname, and +// the buffer-fit check behind the #17 length validation. +#include +#include +#include "helpers/MQTTObserverValidation.h" + +// ---- IATA: exactly 3 alphanumerics --------------------------------------- + +TEST(IataValid, AcceptsThreeLetters) { + EXPECT_TRUE(mqttIataValid("DEN")); + EXPECT_TRUE(mqttIataValid("den")); // case handled (setter uppercases after) + EXPECT_TRUE(mqttIataValid("LAX")); +} + +TEST(IataValid, AcceptsThreeAlphanumerics) { + EXPECT_TRUE(mqttIataValid("D3N")); + EXPECT_TRUE(mqttIataValid("2M0")); +} + +TEST(IataValid, RejectsWrongLength) { + EXPECT_FALSE(mqttIataValid("")); + EXPECT_FALSE(mqttIataValid("D")); + EXPECT_FALSE(mqttIataValid("DE")); + EXPECT_FALSE(mqttIataValid("DENV")); + EXPECT_FALSE(mqttIataValid("DENVER")); +} + +TEST(IataValid, RejectsNonAlphanumeric) { + EXPECT_FALSE(mqttIataValid("D-N")); // topic separator-ish + EXPECT_FALSE(mqttIataValid("D N")); // space + EXPECT_FALSE(mqttIataValid("D/N")); // MQTT topic separator + EXPECT_FALSE(mqttIataValid("D+N")); // MQTT wildcard + EXPECT_FALSE(mqttIataValid("D#N")); // MQTT wildcard +} + +TEST(IataValid, RejectsNull) { + EXPECT_FALSE(mqttIataValid(nullptr)); +} + +// ---- owner key: exactly 64 hex ------------------------------------------- + +static std::string hexKey(int len, char fill = 'a') { return std::string(len, fill); } + +TEST(OwnerKeyValid, Accepts64Hex) { + EXPECT_TRUE(mqttOwnerKeyValid(hexKey(64, 'a').c_str())); + EXPECT_TRUE(mqttOwnerKeyValid(hexKey(64, 'F').c_str())); + EXPECT_TRUE(mqttOwnerKeyValid( + "0123456789abcdefABCDEF0123456789abcdefABCDEF0123456789abcdef0123")); +} + +TEST(OwnerKeyValid, RejectsWrongLength) { + EXPECT_FALSE(mqttOwnerKeyValid("")); + EXPECT_FALSE(mqttOwnerKeyValid(hexKey(63).c_str())); + EXPECT_FALSE(mqttOwnerKeyValid(hexKey(65).c_str())); +} + +TEST(OwnerKeyValid, RejectsNonHex) { + std::string k = hexKey(64); + k[10] = 'g'; // not a hex digit + EXPECT_FALSE(mqttOwnerKeyValid(k.c_str())); + k[10] = 'z'; + EXPECT_FALSE(mqttOwnerKeyValid(k.c_str())); + k[10] = ' '; + EXPECT_FALSE(mqttOwnerKeyValid(k.c_str())); +} + +TEST(OwnerKeyValid, RejectsNull) { + EXPECT_FALSE(mqttOwnerKeyValid(nullptr)); +} + +// ---- NTP hostname --------------------------------------------------------- + +TEST(NtpHostnameValid, AcceptsTypicalHosts) { + EXPECT_TRUE(mqttNtpHostnameValid("pool.ntp.org")); + EXPECT_TRUE(mqttNtpHostnameValid("time.google.com")); + EXPECT_TRUE(mqttNtpHostnameValid("1.2.3.4")); + EXPECT_TRUE(mqttNtpHostnameValid("a")); +} + +TEST(NtpHostnameValid, LengthBoundaryIs63) { + EXPECT_TRUE(mqttNtpHostnameValid(std::string(63, 'a').c_str())); + EXPECT_FALSE(mqttNtpHostnameValid(std::string(64, 'a').c_str())); +} + +TEST(NtpHostnameValid, RejectsEmptyAndNull) { + EXPECT_FALSE(mqttNtpHostnameValid("")); + EXPECT_FALSE(mqttNtpHostnameValid(nullptr)); +} + +TEST(NtpHostnameValid, RejectsLeadingOrTrailingDot) { + EXPECT_FALSE(mqttNtpHostnameValid(".pool.ntp.org")); + EXPECT_FALSE(mqttNtpHostnameValid("pool.ntp.org.")); +} + +TEST(NtpHostnameValid, RejectsInvalidChars) { + EXPECT_FALSE(mqttNtpHostnameValid("a_b")); // underscore + EXPECT_FALSE(mqttNtpHostnameValid("a b")); // space + EXPECT_FALSE(mqttNtpHostnameValid("http://x")); // scheme / slashes +} + +// ---- buffer-fit (the #17 length check) ----------------------------------- + +TEST(ValueFits, FitsWhenShorterThanBuffer) { + EXPECT_TRUE(mqttValueFits("abc", 4)); // 3 < 4 (room for NUL) + EXPECT_TRUE(mqttValueFits("", 1)); // empty fits any 1+ buffer +} + +TEST(ValueFits, RejectsWhenExactlyBufferSizeOrLonger) { + EXPECT_FALSE(mqttValueFits("abcd", 4)); // 4 == 4, no room for NUL + EXPECT_FALSE(mqttValueFits("abcde", 4)); + EXPECT_FALSE(mqttValueFits("x", 1)); +} + +TEST(ValueFits, RealBufferBoundaries) { + // Mirrors the actual MQTTPrefs field sizes the setters pass sizeof() for. + EXPECT_TRUE(mqttValueFits(std::string(63, 'p').c_str(), 64)); // wifi_password[64] + EXPECT_FALSE(mqttValueFits(std::string(64, 'p').c_str(), 64)); + EXPECT_TRUE(mqttValueFits(std::string(31, 's').c_str(), 32)); // wifi_ssid[32] + EXPECT_FALSE(mqttValueFits(std::string(32, 's').c_str(), 32)); + EXPECT_TRUE(mqttValueFits(std::string(47, 't').c_str(), 48)); // slot token[48] + EXPECT_FALSE(mqttValueFits(std::string(48, 't').c_str(), 48)); + EXPECT_TRUE(mqttValueFits(std::string(95, 'x').c_str(), 96)); // slot topic[96] + EXPECT_FALSE(mqttValueFits(std::string(96, 'x').c_str(), 96)); +} + +TEST(ValueFits, RejectsNullOrZeroBuffer) { + EXPECT_FALSE(mqttValueFits(nullptr, 32)); + EXPECT_FALSE(mqttValueFits("abc", 0)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_topic_template/test_topic_template.cpp b/test/test_topic_template/test_topic_template.cpp new file mode 100644 index 00000000..b474dc9d --- /dev/null +++ b/test/test_topic_template/test_topic_template.cpp @@ -0,0 +1,101 @@ +// Host tests for the MQTT custom-topic placeholder expansion +// (src/helpers/MQTTTopicTemplate.h), the pure core of +// MQTTBridge::substituteTopicTemplate. +#include +#include +#include "helpers/MQTTTopicTemplate.h" + +static const char* IATA = "DEN"; +static const char* DEV = "abcdef0123456789"; +static const char* TOK = "tok123"; + +TEST(TopicTemplate, SubstitutesAllPlaceholders) { + char buf[128]; + ASSERT_TRUE(mqttSubstituteTopic("meshcore/{iata}/{device}/{type}", IATA, DEV, TOK, "status", + buf, sizeof(buf))); + EXPECT_STREQ("meshcore/DEN/abcdef0123456789/status", buf); +} + +TEST(TopicTemplate, TokenPlaceholder) { + char buf[128]; + ASSERT_TRUE(mqttSubstituteTopic("meshrank/uplink/{token}/{device}/packets", + IATA, DEV, TOK, "packets", buf, sizeof(buf))); + EXPECT_STREQ("meshrank/uplink/tok123/abcdef0123456789/packets", buf); +} + +TEST(TopicTemplate, RepeatedPlaceholder) { + char buf[64]; + ASSERT_TRUE(mqttSubstituteTopic("{iata}-{iata}", IATA, DEV, TOK, "raw", buf, sizeof(buf))); + EXPECT_STREQ("DEN-DEN", buf); +} + +TEST(TopicTemplate, LiteralWithNoPlaceholders) { + char buf[64]; + ASSERT_TRUE(mqttSubstituteTopic("plain/topic/path", IATA, DEV, TOK, "status", buf, sizeof(buf))); + EXPECT_STREQ("plain/topic/path", buf); +} + +TEST(TopicTemplate, UnknownBracesCopiedVerbatim) { + char buf[64]; + ASSERT_TRUE(mqttSubstituteTopic("a/{bogus}/{iata}", IATA, DEV, TOK, "status", buf, sizeof(buf))); + EXPECT_STREQ("a/{bogus}/DEN", buf); +} + +TEST(TopicTemplate, TypeStringVaries) { + char buf[64]; + mqttSubstituteTopic("{type}", IATA, DEV, TOK, "status", buf, sizeof(buf)); + EXPECT_STREQ("status", buf); + mqttSubstituteTopic("{type}", IATA, DEV, TOK, "packets", buf, sizeof(buf)); + EXPECT_STREQ("packets", buf); + mqttSubstituteTopic("{type}", IATA, DEV, TOK, "raw", buf, sizeof(buf)); + EXPECT_STREQ("raw", buf); +} + +TEST(TopicTemplate, NullValuesSubstituteEmpty) { + char buf[64]; + ASSERT_TRUE(mqttSubstituteTopic("x/{token}/y", IATA, DEV, nullptr, "status", buf, sizeof(buf))); + EXPECT_STREQ("x//y", buf); +} + +TEST(TopicTemplate, OverflowReturnsFalseNoWrite) { + // Substituting {device} (16 chars) into a template won't fit an 8-byte buffer. + char buf[8]; + EXPECT_FALSE(mqttSubstituteTopic("{device}", IATA, DEV, TOK, "status", buf, sizeof(buf))); +} + +TEST(TopicTemplate, LiteralOverflowTruncatesAndNulTerminates) { + char buf[5]; + // Literal longer than the buffer: fills up to buf_size-1 and NUL-terminates. + mqttSubstituteTopic("abcdefghij", IATA, DEV, TOK, "status", buf, sizeof(buf)); + EXPECT_EQ('\0', buf[4]); + EXPECT_EQ((size_t)4, strlen(buf)); +} + +TEST(TopicTemplate, AlwaysNulTerminatedAndBounded) { + // Fuzz-ish: many buffer sizes never overrun and always NUL-terminate. + const char* tmpl = "meshcore/{iata}/{device}/{token}/{type}/tail"; + for (size_t sz = 1; sz <= 80; sz++) { + char buf[96]; + memset(buf, 0x7f, sizeof(buf)); + mqttSubstituteTopic(tmpl, IATA, DEV, TOK, "packets", buf, sz); + EXPECT_LT(strlen(buf), sz) << "size " << sz; // fits with room for NUL + EXPECT_EQ('\0', buf[strlen(buf)]); + } +} + +TEST(TopicTemplate, ZeroBufferOrNullFails) { + char buf[8]; + EXPECT_FALSE(mqttSubstituteTopic("x", IATA, DEV, TOK, "status", buf, 0)); + EXPECT_FALSE(mqttSubstituteTopic("x", IATA, DEV, TOK, "status", nullptr, 8)); +} + +TEST(TopicTemplate, EmptyTemplateReturnsFalse) { + char buf[8]; + EXPECT_FALSE(mqttSubstituteTopic("", IATA, DEV, TOK, "status", buf, sizeof(buf))); + EXPECT_STREQ("", buf); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_webconfig_keys/test_webconfig_keys.cpp b/test/test_webconfig_keys/test_webconfig_keys.cpp new file mode 100644 index 00000000..6158b952 --- /dev/null +++ b/test/test_webconfig_keys/test_webconfig_keys.cpp @@ -0,0 +1,104 @@ +// Host tests for the WebConfig key allowlist / secret / slot-prefix helpers +// (src/helpers/WebConfigKeys.h). These parse attacker-supplied POST keys, so +// coverage of the length-guard and boundary cases matters for safety. +#include +#include "helpers/WebConfigKeys.h" + +// ---- allowlist ------------------------------------------------------------ + +TEST(WebConfigKeys, AllowsKnownScalarKeys) { + EXPECT_TRUE(wcIsAllowedSetKey("name")); + EXPECT_TRUE(wcIsAllowedSetKey("radio")); + EXPECT_TRUE(wcIsAllowedSetKey("repeat")); + EXPECT_TRUE(wcIsAllowedSetKey("wifi.ssid")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt.iata")); + EXPECT_TRUE(wcIsAllowedSetKey("snmp.community")); + EXPECT_TRUE(wcIsAllowedSetKey("timezone.offset")); +} + +TEST(WebConfigKeys, AllowsPerSlotKeys) { + EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.preset")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.server")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.token")); + EXPECT_TRUE(wcIsAllowedSetKey("mqtt6.audience")); // MAX_MQTT_SLOTS == 6 +} + +TEST(WebConfigKeys, RejectsDangerousOrUnknownKeys) { + EXPECT_FALSE(wcIsAllowedSetKey("erase")); + EXPECT_FALSE(wcIsAllowedSetKey("password")); + EXPECT_FALSE(wcIsAllowedSetKey("reboot")); + EXPECT_FALSE(wcIsAllowedSetKey("bogus")); + EXPECT_FALSE(wcIsAllowedSetKey("mqtt1.bogus")); // unknown slot field + EXPECT_FALSE(wcIsAllowedSetKey("")); +} + +TEST(WebConfigKeys, SlotIndexBoundsMatchMaxSlots) { + EXPECT_FALSE(wcIsAllowedSetKey("mqtt0.preset")); // slot 0 invalid + EXPECT_TRUE(wcIsAllowedSetKey("mqtt6.preset")); // last valid slot + EXPECT_FALSE(wcIsAllowedSetKey("mqtt7.preset")); // beyond MAX_MQTT_SLOTS + EXPECT_FALSE(wcIsAllowedSetKey("mqtt9.preset")); +} + +TEST(WebConfigKeys, IsCaseSensitive) { + EXPECT_FALSE(wcIsAllowedSetKey("Name")); + EXPECT_FALSE(wcIsAllowedSetKey("MQTT1.preset")); +} + +// ---- short-key OOB guard -------------------------------------------------- +// The slot-prefix probe indexes key[4..6]; these short strings must be rejected +// without ever reading past the terminator. + +TEST(WebConfigKeys, ShortKeysRejectedSafely) { + EXPECT_FALSE(wcIsSlotKeyPrefix("")); + EXPECT_FALSE(wcIsSlotKeyPrefix("m")); + EXPECT_FALSE(wcIsSlotKeyPrefix("mq")); + EXPECT_FALSE(wcIsSlotKeyPrefix("mqt")); + EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt")); // 4 chars — no digit/dot + EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt1")); // 5 chars — no dot + EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt1.")); // 6 chars — no field char + EXPECT_TRUE(wcIsSlotKeyPrefix("mqtt1.x")); // 7 chars — minimum valid + // Same guard via the public allowlist/secret entry points: + EXPECT_FALSE(wcIsAllowedSetKey("mqtt")); + EXPECT_FALSE(wcIsSecretKey("m")); + EXPECT_FALSE(wcIsSecretKey("mqtt")); +} + +TEST(WebConfigKeys, SlotPrefixDigitRange) { + EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt0.x")); + EXPECT_TRUE(wcIsSlotKeyPrefix("mqtt6.x")); + EXPECT_FALSE(wcIsSlotKeyPrefix("mqtt7.x")); + EXPECT_FALSE(wcIsSlotKeyPrefix("mqttA.x")); // non-digit +} + +// ---- secret classification ------------------------------------------------ + +TEST(WebConfigKeys, SecretKeysDetected) { + EXPECT_TRUE(wcIsSecretKey("wifi.pwd")); + EXPECT_TRUE(wcIsSecretKey("mqtt1.password")); + EXPECT_TRUE(wcIsSecretKey("mqtt3.token")); + EXPECT_TRUE(wcIsSecretKey("mqtt6.password")); +} + +TEST(WebConfigKeys, NonSecretKeysNotFlagged) { + EXPECT_FALSE(wcIsSecretKey("wifi.ssid")); + EXPECT_FALSE(wcIsSecretKey("mqtt1.username")); // username is not masked + EXPECT_FALSE(wcIsSecretKey("mqtt1.server")); + EXPECT_FALSE(wcIsSecretKey("mqtt.origin")); + EXPECT_FALSE(wcIsSecretKey("name")); + EXPECT_FALSE(wcIsSecretKey("")); +} + +TEST(WebConfigKeys, EverySecretKeyIsAlsoAllowed) { + // A secret key must be one the portal can actually set, or the masking is moot. + const char* secrets[] = {"wifi.pwd", "mqtt1.password", "mqtt1.token", + "mqtt6.password", "mqtt6.token"}; + for (const char* k : secrets) { + EXPECT_TRUE(wcIsSecretKey(k)) << k; + EXPECT_TRUE(wcIsAllowedSetKey(k)) << k; + } +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index 2d13237b..f9a81bb1 100644 --- a/variants/heltec_v3/platformio.ini +++ b/variants/heltec_v3/platformio.ini @@ -159,6 +159,16 @@ lib_deps = 0neblock/SNMP_Agent paulstoffregen/Time@1.6.1 +; Emulator build (Wokwi): identical to the observer above but with the LoRa radio +; stubbed (SIM_BUILD -> SimRadio) and WiFi pre-seeded to the Wokwi network so it +; boots straight into WiFi/MQTT/CLI without hardware or flashing. See wokwi/. +[env:Heltec_v3_repeater_observer_mqtt_sim] +extends = env:Heltec_v3_repeater_observer_mqtt +build_flags = + ${env:Heltec_v3_repeater_observer_mqtt.build_flags} + -D SIM_BUILD=1 + -D SIM_WIFI_SSID='"Wokwi-GUEST"' + [env:Heltec_v3_room_server] extends = Heltec_lora32_v3 build_flags = diff --git a/variants/heltec_v3/target.cpp b/variants/heltec_v3/target.cpp index 9590acff..db578d41 100644 --- a/variants/heltec_v3/target.cpp +++ b/variants/heltec_v3/target.cpp @@ -3,6 +3,9 @@ HeltecV3Board board; +#ifdef SIM_BUILD + SimRadio radio_driver(board); // no-op radio for emulator builds +#else #if defined(P_LORA_SCLK) static SPIClass spi; RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); @@ -11,6 +14,7 @@ HeltecV3Board board; #endif WRAPPER_CLASS radio_driver(radio, board); +#endif ESP32RTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); @@ -31,8 +35,10 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); bool radio_init() { fallback_clock.begin(); rtc_clock.begin(Wire); - -#if defined(P_LORA_SCLK) + +#ifdef SIM_BUILD + return true; // no SPI radio to bring up +#elif defined(P_LORA_SCLK) return radio.std_init(&spi); #else return radio.std_init(); @@ -40,7 +46,12 @@ bool radio_init() { } mesh::LocalIdentity radio_new_identity() { +#ifdef SIM_BUILD + SimRNG rng; + return mesh::LocalIdentity(&rng); +#else RadioNoiseListener rng(radio); return mesh::LocalIdentity(&rng); // create new random identity +#endif } diff --git a/variants/heltec_v3/target.h b/variants/heltec_v3/target.h index 2944b384..3ef7b2d7 100644 --- a/variants/heltec_v3/target.h +++ b/variants/heltec_v3/target.h @@ -1,10 +1,17 @@ #pragma once -#define RADIOLIB_STATIC_ONLY 1 -#include -#include -#include -#include +#ifdef SIM_BUILD + // Emulator build (e.g. Wokwi): no SX1262 hardware — use the no-op SimRadio so + // the firmware boots and runs WiFi/MQTT/CLI/display. See src/helpers/sim/. + #include + #include +#else + #define RADIOLIB_STATIC_ONLY 1 + #include + #include + #include + #include +#endif #include #include #include @@ -14,7 +21,11 @@ #endif extern HeltecV3Board board; -extern WRAPPER_CLASS radio_driver; +#ifdef SIM_BUILD + extern SimRadio radio_driver; +#else + extern WRAPPER_CLASS radio_driver; +#endif extern AutoDiscoverRTCClock rtc_clock; extern EnvironmentSensorManager sensors; diff --git a/wokwi.toml b/wokwi.toml new file mode 100644 index 00000000..eaa90c61 --- /dev/null +++ b/wokwi.toml @@ -0,0 +1,16 @@ +# Wokwi config for the ESP32-S3 observer simulation (no hardware, no flashing). +# +# Build the sim firmware first (produces the merged flash image Wokwi needs): +# pio run -e Heltec_v3_repeater_observer_mqtt_sim -t mergebin +# +# Then start the simulation either way: +# - VS Code: install the "Wokwi Simulator" extension, open diagram.json, press play +# - CLI: wokwi-cli . (needs a free WOKWI_CLI_TOKEN) +# +# The firmware is seeded to auto-join the "Wokwi-GUEST" network, so it boots +# straight into WiFi + MQTT. The LoRa radio is stubbed (SimRadio); mesh RX/TX is +# not simulated. Drive the CLI over the serial monitor (get/set/start webconfig). +[wokwi] +version = 1 +firmware = ".pio/build/Heltec_v3_repeater_observer_mqtt_sim/firmware-merged.bin" +elf = ".pio/build/Heltec_v3_repeater_observer_mqtt_sim/firmware.elf"