mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 03:01:27 +00:00
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:
1 parent
b7795720b2
commit
cbbd01b620
23 files changed
+1403
-131
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
};
|
||||
Reference in new issue
Block a user