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.
This commit is contained in:
agessaman
2026-07-18 13:19:27 -07:00
parent b7795720b2
commit cbbd01b620
23 changed files with 1403 additions and 131 deletions
+20
View File
@@ -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:
+18
View File
@@ -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", "", [] ]
]
}
+19
View File
@@ -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);
+2
View File
@@ -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 <suite>` (one).
[env:native]
platform = native
+470
View File
@@ -0,0 +1,470 @@
#!/usr/bin/env python3
"""Local mock of the WebConfig portal backend, for iterating on webui/index.html
in a real browser with no firmware, no flashing, and no paid emulator account.
It serves the real webui/index.html and implements the same /api/* contract as
src/helpers/esp32/WebConfigServer.cpp including the 202+reqid handshake, the
pending -> 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()
+30 -38
View File
@@ -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 <Utils.h>
#ifdef ESP_PLATFORM
#include <WiFi.h>
@@ -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");
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include <stddef.h>
#include <string.h>
// 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;
}
+3
View File
@@ -1,5 +1,8 @@
#pragma once
#include <stdint.h>
#include <string.h> // 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;
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <stddef.h>
#include <string.h>
// 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;
}
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <string.h>
#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 <key> <value>` 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.<field>" 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>.<field>
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;
}
+4 -39
View File
@@ -1,5 +1,6 @@
#include "MQTTBridge.h"
#include "../MQTTMessageBuilder.h"
#include "../MQTTTopicTemplate.h"
#include "../TxtDataHelpers.h"
#include <NTPClient.h>
#include <WiFiUdp.h>
@@ -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) {
+6 -46
View File
@@ -12,6 +12,7 @@
#include <helpers/CommonCLI.h>
#include <helpers/MQTTPresets.h>
#include <helpers/WebConfigKeys.h>
#include <helpers/bridges/MQTTBridge.h>
#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>.<field>
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) {
+75
View File
@@ -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 <Mesh.h>
#include <stdint.h>
#include <stddef.h>
#if defined(ESP_PLATFORM)
#include <esp_system.h> // 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; }
};
+48
View File
@@ -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_<name>/` 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_<name>/test_<name>.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.
@@ -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 <gtest/gtest.h>
#include <cstring>
#include <set>
#include <string>
#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<std::string> 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();
}
@@ -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 <gtest/gtest.h>
#include <string>
#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();
}
@@ -0,0 +1,101 @@
// Host tests for the MQTT custom-topic placeholder expansion
// (src/helpers/MQTTTopicTemplate.h), the pure core of
// MQTTBridge::substituteTopicTemplate.
#include <gtest/gtest.h>
#include <cstring>
#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();
}
@@ -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 <gtest/gtest.h>
#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();
}
+10
View File
@@ -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 =
+13 -2
View File
@@ -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
}
+17 -6
View File
@@ -1,10 +1,17 @@
#pragma once
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <HeltecV3Board.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#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 <helpers/sim/SimRadio.h>
#include <HeltecV3Board.h>
#else
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <HeltecV3Board.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#endif
#include <helpers/AutoDiscoverRTCClock.h>
#include <helpers/SensorManager.h>
#include <helpers/sensors/EnvironmentSensorManager.h>
@@ -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;
+16
View File
@@ -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"