Merge observer firmware with persistent opt-in advert signature verification

Add Waveshare LR1121 observer recipes. Adapt observer advert verification to existing transactional CLI, append-only binary preferences and Companion NVS, preserving default file lengths and WiFi credentials. Reject oversized unsigned advert tails consistently with Mesh::onRecvPacket. Keep current BLE recovery and GPS claim accounting.
This commit is contained in:
mikecarper committed 2026-10-15 13:45:27 -07:00
commit b5e493996c
27 files changed
+641 -48

No files matched your search

+2
View File
@@ -606,6 +606,7 @@ These settings apply across all MQTT slots:
- `get mqtt.packets` - Get packet message setting (on/off)
- `get mqtt.raw` - Get raw message setting (on/off)
- `get mqtt.rx` - Get RX packet uplinking setting (on/off)
- `get mqtt.advert.verify` - Get advert signature filtering setting (on/off)
- `get mqtt.tx` - Get TX packet uplinking setting (on/off/advert)
- `get mqtt.interval` - Get status publish interval
- `get mqtt.neighbors` - Get periodic neighbors publishing setting (on/off; neighbors-enabled builds)
@@ -622,6 +623,7 @@ These settings apply across all MQTT slots:
- `set mqtt.packets on|off` - Enable/disable packet messages
- `set mqtt.raw on|off` - Enable/disable raw messages
- `set mqtt.rx on|off` - Enable/disable RX (received) packet uplinking
- `set mqtt.advert.verify on|off` - Skip received adverts whose signature fails (default off)
- `set mqtt.tx on|off|advert` - Set TX packet uplinking mode:
- `on` - Uplink all transmitted packets
- `advert` - Uplink only this node's own advert packets (self-originated)
+23
View File
@@ -5213,6 +5213,29 @@ USB logging remains independent. On images with both outputs,
---
#### View or change MQTT advert signature filtering
**Usage:**
- `get mqtt.advert.verify`
- `set mqtt.advert.verify <on|off>`
**Parameters:**
- `on`: verify the Ed25519 signature of each received advert and skip publishing any that fail (the same check the mesh applies before relaying)
- `off`: publish received adverts as heard
**Default:** `off`
> **Note:** Adverts are uploaded before the mesh validates them, so a frame corrupted over the air that still passes the LoRa CRC is published with a mangled name or location. Leaving this off keeps that corruption visible to collectors; turning it on hides it from them. Dropped adverts are counted as `badsig=` in `get mqtt.stats`. Takes effect immediately.
On Companion builds, enabling this setting opts into a marked MQTT preference
record. Before rolling back to firmware without this setting, turn it off to
restore the older record format and retain the saved MQTT setup. Ordinary
default-off updates keep the existing record size.
---
#### View or change periodic neighbors publishing (MQTT observer, neighbors feature)
**Usage:**
- `get mqtt.neighbors`
+3
View File
@@ -5189,6 +5189,7 @@ void MyMesh::execCommand(char* cmd, char* reply) {
else if (strcmp(key, "mqtt.packets") == 0) value = _mqtt_prefs.mqtt_packets_enabled ? "on" : "off";
else if (strcmp(key, "mqtt.raw") == 0) value = _mqtt_prefs.mqtt_raw_enabled ? "on" : "off";
else if (strcmp(key, "mqtt.rx") == 0) value = _mqtt_prefs.mqtt_rx_enabled ? "on" : "off";
else if (strcmp(key, "mqtt.advert.verify") == 0) value = _mqtt_prefs.mqtt_advert_verify ? "on" : "off";
else if (strcmp(key, "mqtt.tx") == 0) value = _mqtt_prefs.mqtt_tx_enabled == 2 ? "advert" : _mqtt_prefs.mqtt_tx_enabled ? "on" : "off";
else if (strcmp(key, "mqtt.ntp") == 0) value = _mqtt_prefs.mqtt_ntp_server;
else if (strcmp(key, "mqtt.owner") == 0) value = _mqtt_prefs.mqtt_owner_public_key;
@@ -5459,6 +5460,7 @@ void MyMesh::execCommand(char* cmd, char* reply) {
}
} else if (strcmp(key, "mqtt.status") == 0 || strcmp(key, "mqtt.packets") == 0
|| strcmp(key, "mqtt.raw") == 0 || strcmp(key, "mqtt.rx") == 0
|| strcmp(key, "mqtt.advert.verify") == 0
|| strcmp(key, "snmp") == 0) {
bool enabled;
if (!wcParseBool(value, enabled)) {
@@ -5469,6 +5471,7 @@ void MyMesh::execCommand(char* cmd, char* reply) {
else if (strcmp(key, "mqtt.packets") == 0) _mqtt_prefs.mqtt_packets_enabled = enabled;
else if (strcmp(key, "mqtt.raw") == 0) _mqtt_prefs.mqtt_raw_enabled = enabled;
else if (strcmp(key, "mqtt.rx") == 0) _mqtt_prefs.mqtt_rx_enabled = enabled;
else if (strcmp(key, "mqtt.advert.verify") == 0) _mqtt_prefs.mqtt_advert_verify = enabled;
else _mqtt_prefs.snmp_enabled = enabled;
changed = true;
} else if (strcmp(key, "mqtt.tx") == 0) {
+1 -1
View File
@@ -3436,8 +3436,8 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t
void MyMesh::onControlDataRecv(mesh::Packet* packet) {
uint8_t type = packet->payload[0] & 0xF0; // just test upper 4 bits
if (type == CTL_TYPE_NODE_DISCOVER_REQ && packet->payload_len >= 6
&& discover_limiter.allow(rtc_clock.getCurrentTime())
&& !isHiddenNode() // this node wishes to NOT be discoverable
&& discover_limiter.allow(rtc_clock.getCurrentTime())
) {
int i = 1;
uint8_t filter = packet->payload[i++];
+3 -2
View File
@@ -121,7 +121,7 @@ def default_config(setup_mode):
"mqtt": {
"enabled": False,
"origin": "" if setup_mode else "MockNode", "iata": "" if setup_mode else "DEN",
"status": True, "packets": True, "raw": False, "tx": "advert", "rx": True,
"status": True, "packets": True, "raw": False, "tx": "advert", "rx": True, "advert_verify": False,
"interval": 5, "timezone": "MST7MDT,M3.2.0,M11.1.0", "timezone_offset": -7,
"ntp": "pool.ntp.org", "owner": "", "email": "", "snmp": False,
"snmp_community": "public",
@@ -228,7 +228,8 @@ BOOL_KEYS = {"mqtt.enabled": ("mqtt", "enabled"), "cad": ("radio", "cad"), "radi
"powersaving": ("radio", "powersaving"),
"repeat": ("radio", "repeat"), "mqtt.status": ("mqtt", "status"),
"mqtt.packets": ("mqtt", "packets"), "mqtt.raw": ("mqtt", "raw"),
"mqtt.rx": ("mqtt", "rx"), "snmp": ("mqtt", "snmp"),
"mqtt.rx": ("mqtt", "rx"), "mqtt.advert.verify": ("mqtt", "advert_verify"),
"snmp": ("mqtt", "snmp"),
"mqtt.neighbors": ("mqtt", "neighbors")}
INT_KEYS = {"tx": ("radio", "tx"), "flood.max": ("radio", "flood_max"),
"flood.max.advert": ("radio", "flood_max_advert"),
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <stdint.h>
#include <string.h>
#include <MeshCore.h>
// Signed-message layout of an ADVERT payload, mirroring Mesh::onRecvPacket:
// pub_key(32) + timestamp(4) + signature(64) + app_data. Oversized app_data is
// rejected, like Mesh::onRecvPacket: truncation would accept an unsigned tail.
namespace AdvertSignature {
static const int kHeaderLen = PUB_KEY_SIZE + 4 + SIGNATURE_SIZE;
static const int kMaxMessageLen = PUB_KEY_SIZE + 4 + MAX_ADVERT_DATA_SIZE;
// Builds the signed message into `out` (kMaxMessageLen bytes) and points `sig`
// at the signature. Returns the message length, or -1 for invalid arguments or
// a payload outside the complete signed advert layout. Clears sig on failure.
static inline int buildMessage(const uint8_t* payload, int payload_len,
uint8_t* out, const uint8_t** sig) {
if (sig == nullptr) return -1;
*sig = nullptr;
if (payload == nullptr || out == nullptr || payload_len < kHeaderLen ||
payload_len > kHeaderLen + MAX_ADVERT_DATA_SIZE) return -1;
int app_data_len = payload_len - kHeaderLen;
memcpy(out, payload, PUB_KEY_SIZE + 4);
memcpy(out + PUB_KEY_SIZE + 4, payload + kHeaderLen, app_data_len);
*sig = payload + PUB_KEY_SIZE + 4;
return PUB_KEY_SIZE + 4 + app_data_len;
}
} // namespace AdvertSignature
+2 -2
View File
@@ -2776,11 +2776,11 @@ void CommonCLI::loadMQTTPrefs(
if (MQTTPrefsCodec::repairDisplayPrefs(&_mqtt_prefs)) {
// Old payloads retain valid defaults because the object was defaulted before
// their shorter prefix was copied. This branch repairs only a full/newer
// payload carrying invalid display values.
// payload carrying invalid display or advert-verification values.
if (!_mqtt_prefs_hold) {
mqtt_rewrite_pending = true;
}
MESH_DEBUG_PRINTLN("MQTT: invalid display preferences reset to defaults");
MESH_DEBUG_PRINTLN("MQTT: invalid display/advert preferences reset to defaults");
}
_legacy_tail.valid = false;
+15
View File
@@ -330,6 +330,19 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
_mqtt_prefs.mqtt_rx_enabled = memcmp(&config[8], "on", 2) == 0 ? 1 : 0;
saveObserverPrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "mqtt.advert.verify ", 19) == 0) {
if (strcmp(config + 19, "on") != 0 && strcmp(config + 19, "off") != 0) {
strcpy(reply, "Error: must be on or off");
return true;
}
const uint8_t previous = _mqtt_prefs.mqtt_advert_verify;
_mqtt_prefs.mqtt_advert_verify = strcmp(config + 19, "on") == 0;
if (!saveObserverPrefs()) {
_mqtt_prefs.mqtt_advert_verify = previous;
strcpy(reply, "Error: settings could not be saved");
return true;
}
strcpy(reply, "OK");
} else if (memcmp(config, "mqtt.interval ", 14) == 0) {
uint32_t minutes = _atoi(&config[14]);
if (minutes >= 1 && minutes <= 60) {
@@ -956,6 +969,8 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
sprintf(reply, "> %s", tx_str);
} else if (memcmp(config, "mqtt.rx", 7) == 0) {
sprintf(reply, "> %s", _mqtt_prefs.mqtt_rx_enabled ? "on" : "off");
} else if (strcmp(config, "mqtt.advert.verify") == 0) {
sprintf(reply, "> %s", _mqtt_prefs.mqtt_advert_verify ? "on" : "off");
} else if (memcmp(config, "mqtt.interval", 13) == 0) {
uint32_t minutes = (_mqtt_prefs.mqtt_status_interval + 29999) / 60000;
sprintf(reply, "> %u minutes (%lu ms)", minutes, (unsigned long)_mqtt_prefs.mqtt_status_interval);
+19 -3
View File
@@ -9,8 +9,10 @@
// The ESP32 Companion setup portal stores a raw MQTTPrefs blob in NVS rather
// than the observer's versioned /mqtt_prefs file. Display preferences are not
// used by Companion firmware, so keep writing the last pre-display boundary:
// older firmware can then read a record saved by a newer image after rollback.
// used by Companion firmware. With advert verification off, keep writing the
// last pre-display boundary so rollback preserves a normally configured node.
// Enabling verification opts into a separately marked blob: an old recovery
// record's former padding must never silently enable a new packet filter.
namespace CompanionMqttPrefsNvs {
static constexpr size_t kWriteSize = MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE;
@@ -20,10 +22,24 @@ static constexpr size_t kWriteSize = MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE;
static constexpr size_t kRecoverySize = 2880;
static_assert(sizeof(MQTTPrefs) >= kRecoverySize,
"MQTTPrefs no longer contains the recoverable NVS prefix");
static constexpr size_t kVerifiedSize = 2881;
static constexpr uint8_t kVerifiedMarker = 0xa7;
struct Record {
MQTTPrefs prefs;
uint8_t marker;
};
static_assert(offsetof(Record, marker) == kRecoverySize,
"the marked NVS blob must append exactly one format byte");
static_assert(sizeof(Record) >= kVerifiedSize,
"the marked NVS record must contain its whole serialized blob");
inline size_t writeSizeFor(const MQTTPrefs& prefs) {
return prefs.mqtt_advert_verify != 0 ? kVerifiedSize : kWriteSize;
}
inline bool accepts(uint16_t version, size_t length) {
return version == MQTT_PREFS_VERSION
&& (length == kWriteSize || length == kRecoverySize);
&& (length == kWriteSize || length == kRecoverySize || length == kVerifiedSize);
}
} // namespace CompanionMqttPrefsNvs
+22 -9
View File
@@ -594,12 +594,20 @@ bool CompanionMqttSetupPortal::loadStoredConfig(MQTTPrefs& prefs) {
// default its append-only display tail so adding observer UI preferences
// cannot discard an existing Companion MQTT setup after an update.
const bool shape_ok = CompanionMqttPrefsNvs::accepts(version, length);
MQTTPrefs loaded;
CompanionMqttPrefsNvs::Record record = {};
MQTTPrefs& loaded = record.prefs;
applyMQTTDefaults(&loaded);
const size_t read = shape_ok ? nvs.getBytes(NVS_PREFS_KEY, &loaded, length) : 0;
const size_t read = shape_ok ? nvs.getBytes(NVS_PREFS_KEY, &record, length) : 0;
nvs.end();
if (!shape_ok || read != length) return false;
// The old 2880-byte recovery blob ended with unspecified padding, including
// potentially 1. Only an explicitly marked new blob can opt into filtering.
// Invalid optional metadata must not discard otherwise valid credentials.
if (length != CompanionMqttPrefsNvs::kVerifiedSize ||
record.marker != CompanionMqttPrefsNvs::kVerifiedMarker ||
loaded.mqtt_advert_verify > 1) loaded.mqtt_advert_verify = 0;
if (!storedStringsAreValid(loaded)
|| loaded.mqtt_status_enabled > 1
|| loaded.mqtt_packets_enabled > 1
@@ -663,15 +671,20 @@ bool CompanionMqttSetupPortal::saveEnabled(bool enabled) {
bool CompanionMqttSetupPortal::saveStoredConfig(const MQTTPrefs& prefs) {
Preferences nvs;
if (!nvs.begin(NVS_NAMESPACE, false)) return false;
// Companion does not consume the appended observer display tail. Keeping
// this record at the pre-display boundary makes a firmware rollback retain
// the MQTT setup. loadStoredConfig() still accepts the briefly written 2880
// byte runtime shape so development images remain recoverable.
const size_t written = nvs.putBytes(
NVS_PREFS_KEY, &prefs, CompanionMqttPrefsNvs::kWriteSize);
// Default configuration keeps the rollback-readable pre-display boundary.
// Advert verification and its format marker are one atomic NVS blob write,
// not separate preference keys that can disagree after power loss.
const size_t payload_size = CompanionMqttPrefsNvs::writeSizeFor(prefs);
size_t written;
if (prefs.mqtt_advert_verify != 0) {
const CompanionMqttPrefsNvs::Record record = {prefs, CompanionMqttPrefsNvs::kVerifiedMarker};
written = nvs.putBytes(NVS_PREFS_KEY, &record, payload_size);
} else {
written = nvs.putBytes(NVS_PREFS_KEY, &prefs, payload_size);
}
const size_t version_written = nvs.putUShort(NVS_VERSION_KEY, MQTT_PREFS_VERSION);
nvs.end();
return written == CompanionMqttPrefsNvs::kWriteSize
return written == payload_size
&& version_written == sizeof(uint16_t);
}
+1
View File
@@ -70,6 +70,7 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) {
prefs->mqtt_raw_enabled = 0;
prefs->mqtt_tx_enabled = 2;
prefs->mqtt_rx_enabled = 1;
prefs->mqtt_advert_verify = 0;
prefs->mqtt_status_interval = 300000;
prefs->wifi_power_save = mesh::wifi::kDefaultPowerSave;
+16 -4
View File
@@ -40,21 +40,24 @@ static const size_t kV1PreObserverPayloadSize = MQTT_PREFS_V1_PRE_OBSERVER_PAYLO
static const size_t kV1PreNeighborsPayloadSize = MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE;
static const size_t kV1PreFilterPayloadSize = MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE;
static const size_t kV1PreDisplayPayloadSize = MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE;
static const size_t kV1PreAdvertPayloadSize = MQTT_PREFS_V1_PRE_ADVERT_PAYLOAD_SIZE;
static const size_t kV1BaselinePayloadSize = MQTT_PREFS_V1_FULL_PAYLOAD_SIZE;
static const size_t kEncodedSize = sizeof(MQTTPrefsHeader) + kV1BaselinePayloadSize;
// Shortest payload length that still round-trips this configuration.
//
// The packet-filter and display tails are optional. Their defaults are exactly
// The packet-filter, display and advert-verification tails are optional. Their
// defaults are exactly
// what a decoder supplies when the corresponding tail is missing, so merely
// upgrading does not lengthen the file. That matters because /mqtt_prefs also
// carries WiFi credentials: old firmware that cannot read a longer payload can
// otherwise lose its network configuration after a downgrade. Changing a
// filter or display control deliberately opts the node into the required tail.
// filter, display or verification control deliberately opts into its tail.
inline size_t payloadLenFor(const MQTTPrefs& prefs) {
if (prefs.mqtt_advert_verify != 0) return kV1BaselinePayloadSize;
if (prefs.display_timeout_secs != DISPLAY_TIMEOUT_DEFAULT_SECS ||
prefs.display_flip != 0) {
return kV1BaselinePayloadSize;
return kV1PreAdvertPayloadSize;
}
return MQTTPacketFilter::allMasksDefault(prefs.mqtt_slot_packet_filter,
MQTT_PREFS_SLOT_COUNT)
@@ -62,7 +65,8 @@ inline size_t payloadLenFor(const MQTTPrefs& prefs) {
: kV1PreDisplayPayloadSize;
}
// Clamp the display tail after loading. Returns true when a persisted value was
// Clamp the optional display/verification tails after loading. Retain the helper
// name for callers predating advert verification. Returns true when a value was
// repaired and should be rewritten (unless the source file is being held).
inline bool repairDisplayPrefs(MQTTPrefs* prefs) {
if (prefs == nullptr) return false;
@@ -75,6 +79,10 @@ inline bool repairDisplayPrefs(MQTTPrefs* prefs) {
prefs->display_flip = 0;
repaired = true;
}
if (prefs->mqtt_advert_verify > 1) {
prefs->mqtt_advert_verify = 0;
repaired = true;
}
return repaired;
}
@@ -156,6 +164,10 @@ inline DecodePlan classify(const uint8_t* prefix, size_t prefix_read, size_t fil
if (header.payload_len == kV1BaselinePayloadSize) {
return {Source::Current, false, false, true, kV1BaselinePayloadSize};
}
if (header.payload_len == kV1PreAdvertPayloadSize) {
// Former complete layout. The default keeps advert verification off.
return {Source::Current, false, false, true, kV1PreAdvertPayloadSize};
}
if (header.payload_len == kV1PreDisplayPayloadSize) {
// Written before runtime display controls. Defaults supply timeout and
// flip while preserving the packet-filter tail.
+7 -3
View File
@@ -203,10 +203,10 @@ class MQTTPrefsSerializer : public ConfigSerializer {
class MqttPrefs : public ConfigSerializer {
MQTTPrefs* _prefs;
int32_t _packets_enabled, _raw_enabled, _tx_enabled, _rx_enabled;
int32_t _packets_enabled, _raw_enabled, _tx_enabled, _rx_enabled, _advert_verify;
char _default_iata[sizeof(MQTTPrefs::mqtt_iata)];
bool _seen_origin = false, _seen_iata = false, _seen_packets = false;
bool _seen_raw = false, _seen_tx = false, _seen_rx = false;
bool _seen_raw = false, _seen_tx = false, _seen_rx = false, _seen_advert_verify = false;
bool _seen_status = false, _seen_neighbors = false, _seen_owner = false;
bool _seen_slot1 = false, _seen_slot2 = false, _seen_slot3 = false;
bool _seen_slot4 = false, _seen_slot5 = false, _seen_slot6 = false;
@@ -224,6 +224,7 @@ class MQTTPrefsSerializer : public ConfigSerializer {
defStrict("raw_enabled", _raw_enabled, _seen_raw);
defStrict("tx_enabled", _tx_enabled, _seen_tx);
defStrict("rx_enabled", _rx_enabled, _seen_rx);
defStrict("advert_verify", _advert_verify, _seen_advert_verify);
defStrict("status", _status, _seen_status);
defStrict("neighbors", _neighbors, _seen_neighbors);
defStrict("owner", _owner, _seen_owner);
@@ -239,7 +240,8 @@ class MQTTPrefsSerializer : public ConfigSerializer {
MQTTPrefsPresetValidator preset_valid)
: _prefs(prefs), _packets_enabled(prefs->mqtt_packets_enabled),
_raw_enabled(prefs->mqtt_raw_enabled), _tx_enabled(prefs->mqtt_tx_enabled),
_rx_enabled(prefs->mqtt_rx_enabled), _status(prefs), _neighbors(prefs),
_rx_enabled(prefs->mqtt_rx_enabled), _advert_verify(prefs->mqtt_advert_verify),
_status(prefs), _neighbors(prefs),
_owner(prefs), _slot1(prefs, 0, preset_valid),
_slot2(prefs, 1, preset_valid), _slot3(prefs, 2, preset_valid),
_slot4(prefs, 3, preset_valid), _slot5(prefs, 4, preset_valid),
@@ -257,10 +259,12 @@ class MQTTPrefsSerializer : public ConfigSerializer {
if (_raw_enabled < 0 || _raw_enabled > 1) { _raw_enabled = 0; *repaired = true; }
if (_tx_enabled < 0 || _tx_enabled > 2) { _tx_enabled = 2; *repaired = true; }
if (_rx_enabled < 0 || _rx_enabled > 1) { _rx_enabled = 1; *repaired = true; }
if (_advert_verify < 0 || _advert_verify > 1) { _advert_verify = 0; *repaired = true; }
_prefs->mqtt_packets_enabled = static_cast<uint8_t>(_packets_enabled);
_prefs->mqtt_raw_enabled = static_cast<uint8_t>(_raw_enabled);
_prefs->mqtt_tx_enabled = static_cast<uint8_t>(_tx_enabled);
_prefs->mqtt_rx_enabled = static_cast<uint8_t>(_rx_enabled);
_prefs->mqtt_advert_verify = static_cast<uint8_t>(_advert_verify);
if (_prefs->mqtt_iata[0] != '\0') {
if (!mqttIataValid(_prefs->mqtt_iata)) {
memcpy(_prefs->mqtt_iata, _default_iata, sizeof(_default_iata));
+18 -8
View File
@@ -125,10 +125,13 @@ struct MQTTPrefs {
// Runtime display controls. These are an append-only v1 tail: older payloads
// stop before them and retain the defaults below. The serialized payload ends
// after display_flip at byte 2879; the compiler's final alignment byte is not
// part of the on-flash format.
// after display_flip at byte 2879 on pre-verification firmware.
uint16_t display_timeout_secs;
uint8_t display_flip;
// Drop received adverts whose Ed25519 signature fails before publishing.
// Append-only v1 tail at byte 2879 (formerly final struct padding). Defaults
// off; only enabling verification requires the 2880-byte serialized payload.
uint8_t mqtt_advert_verify;
};
// Neighbor discovery is scheduled with the wrap-safe millis() helpers, whose
@@ -144,7 +147,7 @@ static const uint32_t MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS = MQTT_NEIGHBORS_DEFAUL
static const uint16_t DISPLAY_TIMEOUT_DEFAULT_SECS = 60;
static const uint16_t DISPLAY_TIMEOUT_MAX_SECS = 3600;
// Version-1 has five payload layouts this firmware can decode. Never infer a
// Version-1 has six payload layouts this firmware can decode. Never infer a
// compatible payload from an arbitrary SHORTER size: raw prefs have no
// checksum, so a short length has to match a boundary that was really shipped.
//
@@ -157,18 +160,20 @@ static const uint16_t DISPLAY_TIMEOUT_MAX_SECS = 3600;
// - PRE_NEIGHBORS (2860): full observer tail, no neighbors fields yet.
// - PRE_FILTER (2864): neighbors tail, no per-slot packet filters.
// - PRE_DISPLAY (2876): six filter masks, no runtime display controls.
// - FULL (2879): timeout plus flip, excluding final struct padding.
// - PRE_ADVERT (2879): timeout plus flip, excluding final struct padding.
// - FULL (2880): advert verification consumes that former padding.
//
// FULL is the maximum written, not the default: MQTTPrefsCodec::payloadLenFor()
// keeps emitting PRE_FILTER while every slot and display control has its
// default. A non-default filter writes PRE_DISPLAY; a non-default display
// control writes FULL. See the rollback note there -- /mqtt_prefs also carries
// the WiFi credentials.
// control writes PRE_ADVERT; enabling advert verification writes FULL. See the
// rollback note there -- /mqtt_prefs also carries the WiFi credentials.
static const size_t MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE = 2736;
static const size_t MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE = 2860;
static const size_t MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE = 2864;
static const size_t MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE = 2876;
static const size_t MQTT_PREFS_V1_FULL_PAYLOAD_SIZE = 2879;
static const size_t MQTT_PREFS_V1_PRE_ADVERT_PAYLOAD_SIZE = 2879;
static const size_t MQTT_PREFS_V1_FULL_PAYLOAD_SIZE = 2880;
// /mqtt_prefs starts with a self-describing 8-byte header. Headerless files
// are deployed legacy layouts and continue to be distinguished by size.
@@ -303,8 +308,13 @@ static_assert(offsetof(MQTTPrefs, mqtt_slot_packet_filter) == MQTT_PREFS_V1_PRE_
static_assert(offsetof(MQTTPrefs, display_timeout_secs) == MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE,
"display timeout must begin at the pre-display payload boundary");
static_assert(offsetof(MQTTPrefs, display_flip) + sizeof(MQTTPrefs::display_flip) ==
MQTT_PREFS_V1_FULL_PAYLOAD_SIZE,
MQTT_PREFS_V1_PRE_ADVERT_PAYLOAD_SIZE,
"serialized display tail must end immediately after display_flip");
static_assert(offsetof(MQTTPrefs, mqtt_advert_verify) == MQTT_PREFS_V1_PRE_ADVERT_PAYLOAD_SIZE,
"advert verification must consume only the former final padding byte");
static_assert(offsetof(MQTTPrefs, mqtt_advert_verify) + sizeof(MQTTPrefs::mqtt_advert_verify) ==
MQTT_PREFS_V1_FULL_PAYLOAD_SIZE,
"serialized advert verification tail must end immediately after its flag");
static_assert(sizeof(OldMQTTPrefs) == 472, "frozen pre-slot /mqtt_prefs layout changed");
static_assert(sizeof(PreWifiPowerOldMQTTPrefs) == 472, "frozen pre-WiFi-power /mqtt_prefs layout changed");
static_assert(offsetof(OldMQTTPrefs, wifi_power_save) == 144,
+1 -1
View File
@@ -29,7 +29,7 @@ static const char* const WC_ALLOWED_SET_KEYS[] = {
// MQTTPrefs (WiFi / MQTT / misc observer)
"wifi.ssid", "wifi.pwd", "wifi.powersave", "espnow.channel",
"mqtt.enabled", "mqtt.origin", "mqtt.iata", "mqtt.status", "mqtt.packets", "mqtt.raw",
"mqtt.tx", "mqtt.rx", "mqtt.interval", "mqtt.neighbors", "mqtt.neighbors.interval",
"mqtt.tx", "mqtt.rx", "mqtt.advert.verify", "mqtt.interval", "mqtt.neighbors", "mqtt.neighbors.interval",
"mqtt.ntp", "mqtt.owner", "mqtt.email",
"timezone", "timezone.offset", "snmp", "snmp.community",
};
+22
View File
@@ -11,6 +11,7 @@
#include "../MQTTReplyFormat.h"
#include "../MQTTRuntimeBufferLifecycle.h"
#include "../MQTTTopicRouter.h"
#include "../AdvertSignature.h"
#include "../TxtDataHelpers.h"
#include "../UsbLogging.h"
#include "../WiFiPowerSave.h"
@@ -519,6 +520,14 @@ void MQTTBridge::formatMqttStatsReply(char* buf, size_t bufsize) {
if (b->_filtered_packets > 0) {
replyAppendf(buf, bufsize, &pos, " filt=%lu", b->_filtered_packets);
}
// badsig=<n>: RX adverts dropped by mqtt.advert.verify. Omitted while zero.
if (b->_bad_sig_adverts > 0) {
replyAppendf(buf, bufsize, &pos, " badsig=%lu", b->_bad_sig_adverts);
}
// down=<n>: ready slots that are not connected. Without this the reply cannot
// show an outage at all - sN= counts publishes, and a slot that never connects
// never publishes, so its ok freezes and its err stays 0 for the whole outage.
// Omitted while zero, like filt=, so a healthy node's reply keeps its length.
int down = 0;
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
if (MQTTConnectionHealth::isOutageSlot(
@@ -3931,6 +3940,19 @@ void MQTTBridge::onPacketReceived(mesh::Packet *packet) {
return;
}
// Opt-in: drop adverts that fail verification (RF corruption that passed the
// LoRa CRC). The observer callback runs before Mesh::onRecvPacket validates
// received adverts; its opt-in filter must enforce the same signed layout.
if (_obs->mqtt_advert_verify && packet->getPayloadType() == PAYLOAD_TYPE_ADVERT) {
uint8_t message[AdvertSignature::kMaxMessageLen];
const uint8_t* sig = nullptr;
int msg_len = AdvertSignature::buildMessage(packet->payload, packet->payload_len, message, &sig);
if (msg_len < 0 || !mesh::Identity(packet->payload).verify(sig, message, msg_len)) {
_bad_sig_adverts++;
return;
}
}
// Queue packet for transmission
queuePacket(packet, false);
}
+1
View File
@@ -423,6 +423,7 @@ private:
// on Core 1 (radio callbacks), read on Core 0 for `mqtt.stats`; a torn read of
// a diagnostic counter is harmless, so no atomic is warranted.
unsigned long _filtered_packets = 0;
unsigned long _bad_sig_adverts = 0; // RX adverts dropped by mqtt.advert.verify
// Status publish retry tracking
unsigned long _last_status_retry; // Track last retry attempt (separate from successful publish)
+1
View File
@@ -2188,6 +2188,7 @@ void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) {
mqtt["tx"] = obs->mqtt_tx_enabled == 2 ? "advert"
: obs->mqtt_tx_enabled == 1 ? "on" : "off";
mqtt["rx"] = (bool)obs->mqtt_rx_enabled;
mqtt["advert_verify"] = (bool)obs->mqtt_advert_verify;
mqtt["interval"] = obs->mqtt_status_interval / 60000;
mqtt["neighbors"] = (bool)obs->mqtt_neighbors_enabled;
mqtt["neighbors_interval"] = obs->mqtt_neighbors_interval / 3600000UL;
@@ -0,0 +1,90 @@
#include <gtest/gtest.h>
#include <vector>
#include "helpers/AdvertSignature.h"
namespace {
std::vector<uint8_t> fromHex(const char* hex) {
std::vector<uint8_t> out;
for (const char* p = hex; p[0] && p[1]; p += 2) {
char byte[3] = {p[0], p[1], 0};
out.push_back(static_cast<uint8_t>(strtoul(byte, nullptr, 16)));
}
return out;
}
// ADVERT payload for "LCC Andice Observer" as heard by a working observer.
const char* kAdvertPayload =
"1EEB3527ED66C732185A2F29264A7450DBA40F1686CAE580F72BC80E65B2D835"
"F18EC66A"
"1A4FBA70C4BF0A2BCA115F131A6A0F2D54A6E3F8AFCD893F3414DB9DE31EC87E"
"AD2D21F54C4C9ED1CD40147F10515BFE47CB83D27B8C20B29BF5B2A61BDA430B"
"92FCEFD50106D52AFA4C434320416E64696365204F62736572766572";
} // namespace
TEST(AdvertSignature, BuildsPubKeyTimestampAndAppData) {
std::vector<uint8_t> payload = fromHex(kAdvertPayload);
uint8_t message[AdvertSignature::kMaxMessageLen];
const uint8_t* sig = nullptr;
int len = AdvertSignature::buildMessage(payload.data(), static_cast<int>(payload.size()),
message, &sig);
int app_data_len = static_cast<int>(payload.size()) - AdvertSignature::kHeaderLen;
ASSERT_EQ(PUB_KEY_SIZE + 4 + app_data_len, len);
EXPECT_EQ(payload.data() + PUB_KEY_SIZE + 4, sig);
EXPECT_EQ(0, memcmp(message, payload.data(), PUB_KEY_SIZE + 4));
EXPECT_EQ(0, memcmp(message + PUB_KEY_SIZE + 4, payload.data() + AdvertSignature::kHeaderLen,
app_data_len));
}
TEST(AdvertSignature, RejectsPayloadShorterThanHeader) {
uint8_t payload[AdvertSignature::kHeaderLen - 1] = {};
uint8_t message[AdvertSignature::kMaxMessageLen];
const uint8_t* sig = nullptr;
EXPECT_EQ(-1, AdvertSignature::buildMessage(payload, sizeof(payload), message, &sig));
EXPECT_EQ(-1, AdvertSignature::buildMessage(nullptr, 0, message, &sig));
}
TEST(AdvertSignature, HeaderOnlyPayloadSignsPubKeyAndTimestamp) {
uint8_t payload[AdvertSignature::kHeaderLen];
for (int i = 0; i < AdvertSignature::kHeaderLen; i++) payload[i] = static_cast<uint8_t>(i);
uint8_t message[AdvertSignature::kMaxMessageLen];
const uint8_t* sig = nullptr;
EXPECT_EQ(PUB_KEY_SIZE + 4,
AdvertSignature::buildMessage(payload, sizeof(payload), message, &sig));
}
// Oversized unsigned tails must not bypass the observer's opt-in filter.
TEST(AdvertSignature, RejectsOversizedAppDataLikeHardenedMesh) {
uint8_t payload[AdvertSignature::kHeaderLen + MAX_ADVERT_DATA_SIZE + 10];
for (size_t i = 0; i < sizeof(payload); i++) payload[i] = static_cast<uint8_t>(i);
uint8_t message[AdvertSignature::kMaxMessageLen];
const uint8_t* sig = nullptr;
int len = AdvertSignature::buildMessage(payload, sizeof(payload), message, &sig);
ASSERT_EQ(-1, len);
EXPECT_EQ(nullptr, sig);
}
TEST(AdvertSignature, ChecksOutputArgumentsAndClearsStaleSignature) {
uint8_t payload[AdvertSignature::kHeaderLen + MAX_ADVERT_DATA_SIZE] = {};
uint8_t message[AdvertSignature::kMaxMessageLen];
const uint8_t* sig = payload;
EXPECT_EQ(-1, AdvertSignature::buildMessage(payload, -1, message, &sig));
EXPECT_EQ(nullptr, sig);
EXPECT_EQ(-1, AdvertSignature::buildMessage(payload, sizeof(payload), nullptr, &sig));
EXPECT_EQ(nullptr, sig);
EXPECT_EQ(-1, AdvertSignature::buildMessage(payload, sizeof(payload), message, nullptr));
EXPECT_EQ(AdvertSignature::kMaxMessageLen,
AdvertSignature::buildMessage(payload, sizeof(payload), message, &sig));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+47 -3
View File
@@ -22,6 +22,7 @@ def function(source, signature):
PREAMBLE = r"""
#include <cassert>
#include <initializer_list>
#include <cctype>
#include <cstring>
#include <helpers/CompanionMqttPrefsNvs.h>
@@ -47,20 +48,33 @@ void applyMQTTDefaults(MQTTPrefs* p) {
memset(p, 0, sizeof(*p));
for (auto& preset : p->mqtt_slot_preset) strcpy(preset, "none");
}
MQTTPrefs saved;
CompanionMqttPrefsNvs::Record persisted;
MQTTPrefs& saved = persisted.prefs;
size_t saved_length = CompanionMqttPrefsNvs::kWriteSize;
struct Preferences {
bool begin(const char*, bool) { return true; }
bool isKey(const char*) { return true; }
uint16_t getUShort(const char*, uint16_t) { return MQTT_PREFS_VERSION; }
size_t getBytesLength(const char*) { return CompanionMqttPrefsNvs::kWriteSize; }
size_t getBytesLength(const char*) { return saved_length; }
size_t getBytes(const char*, void* dest, size_t length) {
memcpy(dest, &saved, length);
memcpy(dest, &persisted, length);
return length;
}
size_t putBytes(const char*, const void* source, size_t length) {
memset(&persisted, 0, sizeof(persisted));
memcpy(&persisted, source, length);
saved_length = length;
return length;
}
size_t putUShort(const char*, uint16_t version) {
assert(version == MQTT_PREFS_VERSION);
return sizeof(version);
}
void end() {}
};
struct CompanionMqttSetupPortal {
static bool loadStoredConfig(MQTTPrefs&);
static bool saveStoredConfig(const MQTTPrefs&);
static bool hasConfiguredSlot(const MQTTPrefs&);
};
"""
@@ -76,6 +90,35 @@ int main() {
assert(memcmp(&result, &saved, sizeof(saved)) == 0);
assert(!CompanionMqttSetupPortal::hasConfiguredSlot(result));
// Enabling verification persists the appended flag, without lengthening
// ordinary default-off Companion records or losing any broker credentials.
MQTTPrefs enabled = result;
enabled.mqtt_advert_verify = 1;
assert(CompanionMqttSetupPortal::saveStoredConfig(enabled));
assert(saved_length == 2881);
assert(CompanionMqttSetupPortal::loadStoredConfig(result));
assert(result.mqtt_advert_verify == 1);
assert(strcmp(result.mqtt_slot_password[0], "keep-this-secret") == 0);
// Every old recovery padding value is unconditionally default-off.
saved_length=2880;
for (uint8_t padding : {uint8_t(0), uint8_t(1), uint8_t(255)}) {
saved.mqtt_advert_verify=padding;
assert(CompanionMqttSetupPortal::loadStoredConfig(result));
assert(result.mqtt_advert_verify==0);
assert(strcmp(result.mqtt_slot_password[0], "keep-this-secret")==0);
}
saved_length=2881;
saved.mqtt_advert_verify=1; persisted.marker=0; // malformed marker
assert(CompanionMqttSetupPortal::loadStoredConfig(result));
assert(result.mqtt_advert_verify==0);
persisted.marker=CompanionMqttPrefsNvs::kVerifiedMarker;
saved.mqtt_advert_verify=255;
assert(CompanionMqttSetupPortal::loadStoredConfig(result));
assert(result.mqtt_advert_verify == 0);
assert(strcmp(result.mqtt_slot_password[0], "keep-this-secret") == 0);
assert(CompanionMqttSetupPortal::saveStoredConfig(result));
assert(saved_length == 2876);
// Saving a custom broker in stages must not reset it on the next boot.
strcpy(saved.mqtt_slot_preset[0], "custom");
assert(CompanionMqttSetupPortal::loadStoredConfig(result));
@@ -143,6 +186,7 @@ class CompanionMqttConfigTests(unittest.TestCase):
for signature in (
"static bool isThreeLetterCode",
"bool CompanionMqttSetupPortal::loadStoredConfig",
"bool CompanionMqttSetupPortal::saveStoredConfig",
"bool CompanionMqttSetupPortal::hasConfiguredSlot",
):
actual += function(source, signature)
+132
View File
@@ -0,0 +1,132 @@
"""Exercise the production MQTT advert gate and transactional CLI on the host."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
def compile_run(source):
with tempfile.TemporaryDirectory(prefix="mqtt-advert-verify-") as directory:
cpp, binary = Path(directory) / "test.cpp", Path(directory) / "test"
cpp.write_text(source, encoding="utf-8")
result = subprocess.run([
"g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie", "-I", str(ROOT / "src"),
str(cpp), "-o", str(binary),
], text=True, capture_output=True)
if result.returncode:
raise AssertionError(result.stdout + result.stderr)
subprocess.run([str(binary)], check=True)
class MqttAdvertVerifyTests(unittest.TestCase):
def test_actual_queue_gate_rejects_bad_and_unsigned_tails_only_when_enabled(self):
source = (ROOT / "src/helpers/bridges/MQTTBridge.cpp").read_text()
method = extract_braced(source, "void MQTTBridge::onPacketReceived(")
compile_run(r'''
#include <cassert>
#include <initializer_list>
#include <cstdint>
#include <cstring>
#include <helpers/AdvertSignature.h>
constexpr unsigned PAYLOAD_TYPE_ADVERT=4, PAYLOAD_TYPE_TEXT=2;
static unsigned verifies=0;
static bool valid_signature=true;
namespace mesh {
struct Packet {
uint8_t payload[184] = {};
int payload_len=100;
unsigned type=PAYLOAD_TYPE_ADVERT;
unsigned getPayloadType() const { return type; }
};
struct Identity {
const uint8_t* pub;
explicit Identity(const uint8_t* p) : pub(p) {}
bool verify(const uint8_t* sig, const uint8_t* message, int size) {
++verifies;
assert(sig == pub+36 && size>=36 && size<=68);
assert(!memcmp(pub,message,36));
if(size>36) assert(!memcmp(pub+100,message+36,size-36));
return valid_signature;
}
};
}
struct MQTTBridge {
struct Obs { bool mqtt_packets_enabled=true, mqtt_rx_enabled=true, mqtt_advert_verify=false; } obs;
Obs* _obs=&obs;
bool _initialized=true, type_allowed=true, scope_allowed=true;
struct Stop { bool value=false; bool load(int)const{return value;} } _stop_requested;
unsigned _filtered_packets=0, _bad_sig_adverts=0, queued=0;
bool shouldQueuePacketType(unsigned, bool& filtered) { filtered=!type_allowed; return type_allowed; }
bool allowsPacket(mesh::Packet*) { return scope_allowed; }
void queuePacket(mesh::Packet*,bool tx){assert(!tx);++queued;}
void onPacketReceived(mesh::Packet*);
};
namespace std { constexpr int memory_order_acquire=0; }
''' + method + r'''
int main() {
MQTTBridge bridge; mesh::Packet packet;
for(unsigned i=0;i<sizeof(packet.payload);++i) packet.payload[i]=uint8_t(i);
// Default off preserves the observer's existing publish-as-heard behavior.
valid_signature=false; bridge.onPacketReceived(&packet);
assert(bridge.queued==1 && verifies==0 && bridge._bad_sig_adverts==0);
bridge.obs.mqtt_advert_verify=true;
bridge.onPacketReceived(&packet);
assert(bridge.queued==1 && verifies==1 && bridge._bad_sig_adverts==1);
valid_signature=true; packet.payload_len=132;
bridge.onPacketReceived(&packet);
assert(bridge.queued==2 && verifies==2);
// Valid signed prefixes cannot bless an appended unsigned byte.
for(int size : {99,133,184}) {
packet.payload_len=size; bridge.onPacketReceived(&packet);
assert(bridge.queued==2 && verifies==2);
}
assert(bridge._bad_sig_adverts==4);
packet.type=PAYLOAD_TYPE_TEXT; bridge.onPacketReceived(&packet);
assert(bridge.queued==3 && verifies==2);
packet.type=PAYLOAD_TYPE_ADVERT; packet.payload_len=100;
bridge.scope_allowed=false; bridge.onPacketReceived(&packet);
assert(bridge.queued==3 && verifies==2 && bridge._filtered_packets==1);
bridge.scope_allowed=true; bridge.type_allowed=false; bridge.onPacketReceived(&packet);
assert(bridge.queued==3 && verifies==2 && bridge._filtered_packets==2);
}
''')
def test_actual_observer_setter_rejects_typos_and_rolls_back_failed_saves(self):
source = (ROOT / "src/helpers/CommonCLI_Observer.cpp").read_text()
branch = extract_braced(source, 'if (memcmp(config, "mqtt.advert.verify ", 19) == 0)')
compile_run(r'''
#include <cassert>
#include <cstdint>
#include <cstring>
struct CLI {
struct { uint8_t mqtt_advert_verify=0; } _mqtt_prefs;
bool save_ok=true; unsigned saves=0;
bool saveObserverPrefs(){++saves;return save_ok;}
bool set(const char* config,char* reply) {
''' + branch + r'''
else return false;
return true;
}
};
int main() {
CLI cli; char reply[160]={};
assert(cli.set("mqtt.advert.verify on",reply));
assert(cli.saves==1 && cli._mqtt_prefs.mqtt_advert_verify==1 && !strcmp(reply,"OK"));
assert(cli.set("mqtt.advert.verify onx",reply));
assert(cli.saves==1 && cli._mqtt_prefs.mqtt_advert_verify==1 && !strncmp(reply,"Error:",6));
cli.save_ok=false; assert(cli.set("mqtt.advert.verify off",reply));
assert(cli.saves==2 && cli._mqtt_prefs.mqtt_advert_verify==1 && !strncmp(reply,"Error:",6));
cli.save_ok=true; assert(cli.set("mqtt.advert.verify off",reply));
assert(cli.saves==3 && cli._mqtt_prefs.mqtt_advert_verify==0 && !strcmp(reply,"OK"));
}
''')
if __name__ == "__main__":
unittest.main()
+6
View File
@@ -75,6 +75,7 @@ struct MQTTBridge {
void* _packet_queue_handle = reinterpret_cast<void*>(1);
int _queue_count = 3, _skipped_publishes = 0;
unsigned long _filtered_packets = 0;
unsigned long _bad_sig_adverts = 0;
static constexpr int MAX_QUEUE_SIZE = 50;
bool isSlotReady(int) const { return true; }
static void formatMqttStatsReply(char*, size_t);
@@ -114,6 +115,10 @@ int main() {
#endif
// Normal five-slot stats still fit the fixed LoRa reply budget.
assert(strstr(reply, "s5=123/4") != nullptr);
assert(strstr(reply, "badsig=") == nullptr);
bridge._bad_sig_adverts = 7;
MQTTBridge::formatMqttStatsReply(reply, sizeof(reply));
assert(strstr(reply, "badsig=7") != nullptr);
bridge.logMemoryStatus();
#ifdef ESP_PLATFORM
@@ -126,6 +131,7 @@ int main() {
struct { char reply[32]; unsigned char guard[8]; } bounded;
memset(&bounded, 0xA5, sizeof(bounded));
bridge._filtered_packets = 4294967295UL;
bridge._bad_sig_adverts = 4294967295UL;
dma_free = 4294967295UL;
dma_largest = 4294967295UL;
MQTTBridge::formatMqttStatsReply(bounded.reply, sizeof(bounded.reply));
@@ -242,6 +242,7 @@ TEST(MQTTPrefsCodec, CurrentVersionedPayloadRoundTripsExactly) {
}
source.display_timeout_secs = 300;
source.display_flip = 1;
source.mqtt_advert_verify = 1;
std::vector<uint8_t> bytes(Codec::kEncodedSize);
ASSERT_EQ(Codec::kEncodedSize, Codec::encode(source, bytes.data(), bytes.size()));
@@ -331,11 +332,12 @@ TEST(MQTTPrefsCodec, EncodeRefusesAnOutputBufferShorterThanItsChosenPayload) {
MQTTPrefs longest = defaults();
longest.mqtt_slot_packet_filter[0] = 0;
longest.display_flip = 1;
longest.mqtt_advert_verify = 1;
EXPECT_EQ(0u, Codec::encode(longest, bytes.data(), short_size));
EXPECT_EQ(Codec::kEncodedSize, Codec::encode(longest, bytes.data(), bytes.size()));
}
TEST(MQTTPrefsCodec, EitherNonDefaultDisplayControlOptsIntoTheFullTail) {
TEST(MQTTPrefsCodec, EitherNonDefaultDisplayControlKeepsThePreAdvertTail) {
for (int scenario = 0; scenario < 2; ++scenario) {
MQTTPrefs source = defaults();
if (scenario == 0) {
@@ -344,18 +346,70 @@ TEST(MQTTPrefsCodec, EitherNonDefaultDisplayControlOptsIntoTheFullTail) {
source.display_flip = 1;
}
ASSERT_EQ(Codec::kV1BaselinePayloadSize, Codec::payloadLenFor(source));
ASSERT_EQ(Codec::kV1PreAdvertPayloadSize, Codec::payloadLenFor(source));
std::vector<uint8_t> bytes(Codec::kEncodedSize);
ASSERT_EQ(Codec::kEncodedSize,
Codec::encode(source, bytes.data(), bytes.size()));
const size_t written = Codec::encode(source, bytes.data(), bytes.size());
ASSERT_EQ(sizeof(MQTTPrefsHeader) + Codec::kV1PreAdvertPayloadSize, written);
bytes.resize(written);
const Codec::DecodePlan plan = classify(bytes);
ASSERT_EQ(Codec::Source::Current, plan.source);
ASSERT_EQ(Codec::kV1BaselinePayloadSize, plan.payload_len);
ASSERT_EQ(Codec::kV1PreAdvertPayloadSize, plan.payload_len);
MQTTPrefs loaded = defaults();
memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len);
EXPECT_EQ(source.display_timeout_secs, loaded.display_timeout_secs);
EXPECT_EQ(source.display_flip, loaded.display_flip);
EXPECT_EQ(0, loaded.mqtt_advert_verify);
}
}
TEST(MQTTPrefsCodec, AdvertVerifyRoundTripsInOneAppendOnlyByteAndDefaultsOffOnOldFiles) {
MQTTPrefs source = defaults();
strcpy(source.wifi_ssid, "keep-network");
strcpy(source.mqtt_slot_password[0], "keep-credentials");
EXPECT_EQ(0, source.mqtt_advert_verify);
EXPECT_EQ(2864u, Codec::payloadLenFor(source));
source.mqtt_advert_verify = 1;
EXPECT_EQ(2880u, Codec::payloadLenFor(source));
std::vector<uint8_t> bytes(Codec::kEncodedSize);
ASSERT_EQ(bytes.size(), Codec::encode(source, bytes.data(), bytes.size()));
EXPECT_EQ(1, bytes[sizeof(MQTTPrefsHeader) + 2879]);
const Codec::DecodePlan plan = classify(bytes);
ASSERT_EQ(2880u, plan.payload_len);
MQTTPrefs loaded = defaults();
memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len);
EXPECT_EQ(1, loaded.mqtt_advert_verify);
EXPECT_STREQ("keep-network", loaded.wifi_ssid);
EXPECT_STREQ("keep-credentials", loaded.mqtt_slot_password[0]);
// Each shipped older v1 boundary leaves the new default intact.
const auto complete = bytes;
for (size_t boundary : {size_t(2736), size_t(2860), size_t(2864),
size_t(2876), size_t(2879)}) {
bytes = complete;
bytes.resize(sizeof(MQTTPrefsHeader) + boundary);
writeHeader(&bytes, MQTT_PREFS_VERSION, static_cast<uint16_t>(boundary));
const Codec::DecodePlan older = classify(bytes);
ASSERT_EQ(Codec::Source::Current, older.source) << boundary;
ASSERT_EQ(boundary, older.payload_len);
loaded = defaults();
memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), older.payload_len);
EXPECT_EQ(0, loaded.mqtt_advert_verify) << boundary;
EXPECT_STREQ("keep-network", loaded.wifi_ssid);
}
}
TEST(MQTTPrefsCodec, InvalidAdvertVerifyRepairsWithoutDiscardingCredentials) {
MQTTPrefs prefs = defaults();
strcpy(prefs.wifi_ssid, "keep-network");
prefs.mqtt_advert_verify = 1;
EXPECT_FALSE(Codec::repairDisplayPrefs(&prefs));
for (uint8_t value : {uint8_t(2), uint8_t(255)}) {
prefs.mqtt_advert_verify = value;
EXPECT_TRUE(Codec::repairDisplayPrefs(&prefs));
EXPECT_EQ(0, prefs.mqtt_advert_verify);
EXPECT_EQ(2864u, Codec::payloadLenFor(prefs));
EXPECT_STREQ("keep-network", prefs.wifi_ssid);
}
}
@@ -655,6 +709,12 @@ TEST(MQTTPrefsCodec, LongerDeclaredLengthWithoutTheBytesIsStillCorrupt) {
}
TEST(CompanionMqttPrefsNvs, WritesRollbackReadableShapeAndAcceptsRecoveryShape) {
MQTTPrefs prefs = defaults();
EXPECT_EQ(2876u, CompanionMqttPrefsNvs::writeSizeFor(prefs));
prefs.mqtt_advert_verify = 1;
EXPECT_EQ(2881u, CompanionMqttPrefsNvs::writeSizeFor(prefs));
prefs.mqtt_advert_verify = 0;
EXPECT_EQ(2876u, CompanionMqttPrefsNvs::writeSizeFor(prefs));
EXPECT_EQ(MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE,
CompanionMqttPrefsNvs::kWriteSize);
EXPECT_LT(CompanionMqttPrefsNvs::kWriteSize, sizeof(MQTTPrefs));
@@ -663,6 +723,8 @@ TEST(CompanionMqttPrefsNvs, WritesRollbackReadableShapeAndAcceptsRecoveryShape)
MQTT_PREFS_VERSION, CompanionMqttPrefsNvs::kWriteSize));
EXPECT_TRUE(CompanionMqttPrefsNvs::accepts(
MQTT_PREFS_VERSION, CompanionMqttPrefsNvs::kRecoverySize));
EXPECT_TRUE(CompanionMqttPrefsNvs::accepts(
MQTT_PREFS_VERSION, CompanionMqttPrefsNvs::kVerifiedSize));
EXPECT_EQ(2880u, CompanionMqttPrefsNvs::kRecoverySize);
EXPECT_LE(CompanionMqttPrefsNvs::kRecoverySize, sizeof(MQTTPrefs));
EXPECT_FALSE(CompanionMqttPrefsNvs::accepts(
@@ -154,7 +154,7 @@ TEST(MQTTPrefsJsonImport, DecodesEveryObserverGroupAndDisplayTail) {
"wifi:{ssid:\"mesh-net\",password:\"p\\\\\\\"ass\\nword\",power_save:2},"
"time:{timezone:\"MST7MDT\",utc_offset:-6,ntp_server:\"time.example\"},"
"mqtt:{origin:\"observer-one\",iata:\"sea\",packets_enabled:1,"
"raw_enabled:1,tx_enabled:1,rx_enabled:0,"
"raw_enabled:1,tx_enabled:1,rx_enabled:0,advert_verify:1,"
"status:{enabled:1,interval_ms:60000},"
"neighbors:{enabled:1,interval_ms:43200000},"
"owner:{public_key:\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\","
@@ -182,6 +182,7 @@ TEST(MQTTPrefsJsonImport, DecodesEveryObserverGroupAndDisplayTail) {
EXPECT_STREQ("SEA", candidate.mqtt_iata);
EXPECT_EQ(1, candidate.mqtt_raw_enabled);
EXPECT_EQ(0, candidate.mqtt_rx_enabled);
EXPECT_EQ(1, candidate.mqtt_advert_verify);
EXPECT_EQ(60000u, candidate.mqtt_status_interval);
EXPECT_EQ(MQTT_NEIGHBORS_MIN_INTERVAL_MS,
candidate.mqtt_neighbors_interval);
@@ -213,6 +214,7 @@ TEST(MQTTPrefsJsonImport, MissingFieldsRetainCurrentBuildDefaults) {
EXPECT_EQ(0, candidate.wifi_power_save);
EXPECT_EQ(300000u, candidate.mqtt_status_interval);
EXPECT_EQ(60, candidate.display_timeout_secs);
EXPECT_EQ(0, candidate.mqtt_advert_verify);
}
TEST(MQTTPrefsJsonImport, SemanticRepairsUseSchemaAndCurrentWifiDefaults) {
@@ -221,7 +223,7 @@ TEST(MQTTPrefsJsonImport, SemanticRepairsUseSchemaAndCurrentWifiDefaults) {
const char* text =
"{version:1,wifi:{power_save:9},time:{utc_offset:99,ntp_server:\"bad/host\"},"
"mqtt:{iata:\"bad!\",packets_enabled:-1,raw_enabled:2,tx_enabled:9,"
"rx_enabled:2,status:{enabled:3,interval_ms:10},"
"rx_enabled:2,advert_verify:5,status:{enabled:3,interval_ms:10},"
"neighbors:{enabled:2,interval_ms:100},"
"slot1:{preset:\"bogus\",port:-1,packet_filter:70000}},"
"snmp:{enabled:2},radio:{watchdog_min:121},"
@@ -238,6 +240,7 @@ TEST(MQTTPrefsJsonImport, SemanticRepairsUseSchemaAndCurrentWifiDefaults) {
EXPECT_EQ(0, candidate.mqtt_raw_enabled);
EXPECT_EQ(2, candidate.mqtt_tx_enabled);
EXPECT_EQ(1, candidate.mqtt_rx_enabled);
EXPECT_EQ(0, candidate.mqtt_advert_verify);
EXPECT_EQ(300000u, candidate.mqtt_status_interval);
EXPECT_EQ(MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS,
candidate.mqtt_neighbors_interval);
@@ -267,6 +270,9 @@ TEST(MQTTPrefsJsonImport, RejectsKnownTypeDuplicateLengthAndOverflowErrors) {
"{version:{x:1}}",
"{version:1,mqtt:1}",
"{version:1,mqtt:{slot1:1}}",
"{version:1,mqtt:{advert_verify:true}}",
"{version:1,mqtt:{advert_verify:\"on\"}}",
"{version:1,mqtt:{advert_verify:1,advert_verify:0}}",
"{version:1,mqtt:{status:{enabled:1},status:{interval_ms:60000}}}"}) {
MQTTPrefs candidate = defaults;
EXPECT_FALSE(Import::loaded(decodeText(text, defaults, &candidate))) << text;
@@ -339,16 +345,31 @@ TEST(MQTTPrefsJsonImport, ParsedDisplayPrefsRouteThroughFullAtomicBinaryCommit)
ASSERT_EQ(Import::Result::Loaded,
decodeText("{version:1,display:{timeout_s:45,flip:1}}",
defaults, &candidate));
EXPECT_EQ(MQTT_PREFS_V1_FULL_PAYLOAD_SIZE,
EXPECT_EQ(MQTT_PREFS_V1_PRE_ADVERT_PAYLOAD_SIZE,
MQTTPrefsCodec::payloadLenFor(candidate));
CommitStore store(true);
ASSERT_EQ(Atomic::Result::Committed, commitPrefs(&store, candidate));
ASSERT_EQ(sizeof(MQTTPrefsHeader) + MQTT_PREFS_V1_FULL_PAYLOAD_SIZE,
ASSERT_EQ(sizeof(MQTTPrefsHeader) + MQTT_PREFS_V1_PRE_ADVERT_PAYLOAD_SIZE,
store.primary.size());
MQTTPrefsHeader header = {};
memcpy(&header, store.primary.data(), sizeof(header));
EXPECT_EQ(MQTT_PREFS_V1_FULL_PAYLOAD_SIZE, header.payload_len);
EXPECT_EQ(MQTT_PREFS_V1_PRE_ADVERT_PAYLOAD_SIZE, header.payload_len);
}
TEST(MQTTPrefsJsonImport, AdvertVerifyDefaultsOffRoundTripsAndPersistsThroughBinaryCommit) {
const MQTTPrefs defaults = makeDefaults();
EXPECT_EQ(0, defaults.mqtt_advert_verify);
MQTTPrefs candidate = defaults;
ASSERT_EQ(Import::Result::Loaded,
decodeText("{version:1,mqtt:{advert_verify:1}}", defaults, &candidate));
ASSERT_EQ(1, candidate.mqtt_advert_verify);
CommitStore store(true);
ASSERT_EQ(Atomic::Result::Committed, commitPrefs(&store, candidate));
ASSERT_EQ(sizeof(MQTTPrefsHeader) + 2880u, store.primary.size());
MQTTPrefs loaded = defaults;
memcpy(&loaded, store.primary.data() + sizeof(MQTTPrefsHeader), 2880);
EXPECT_EQ(1, loaded.mqtt_advert_verify);
}
TEST(MQTTPrefsJsonImport, FailedAtomicPublishPreservesPreviousBinaryImage) {
+10 -2
View File
@@ -47,6 +47,10 @@ HARNESS = r'''
#define MAX_ESPNOW_PACKET_SIZE 256
unsigned millis() { return 100; }
namespace mesh {
struct Identity {
explicit Identity(const uint8_t*) {}
bool verify(const uint8_t*,const uint8_t*,int) {assert(false);return false;}
};
namespace espnow {
struct ESPNowRawFrames {uint8_t count=0;uint16_t lengths[2]={};uint8_t data[2][256]={};};
bool encodeEspNowRawFrames(const uint8_t*,size_t,ESPNowRawFrames& f) {f.count=1;return true;}
@@ -57,6 +61,10 @@ namespace bridge {
const char* espNowFormatName(int) {return "raw";}
}
}
namespace AdvertSignature {
constexpr unsigned kMaxMessageLen=68;
int buildMessage(const uint8_t*,int,uint8_t*,const uint8_t**) {assert(false);return -1;}
}
struct Seen {
unsigned checks=0,marks=0;
bool wasSeen(mesh::Packet*) {++checks;return false;}
@@ -112,11 +120,11 @@ struct ESPNowBridge : BridgeBase {
};
struct MQTTBridge : BridgeBase {
std::atomic<bool> _stop_requested{false};
struct Obs {bool mqtt_packets_enabled=true,mqtt_rx_enabled=true;uint8_t mqtt_tx_enabled=1;} obs;
struct Obs {bool mqtt_packets_enabled=true,mqtt_rx_enabled=true,mqtt_advert_verify=false;uint8_t mqtt_tx_enabled=1;} obs;
Obs* _obs=&obs;
struct Identity {uint8_t pub_key[32]={};} id;
Identity* _identity=&id;
unsigned _filtered_packets=0;
unsigned _filtered_packets=0,_bad_sig_adverts=0;
bool _staged_raw_valid=false;
bool shouldQueuePacketType(uint8_t,bool&) {return true;}
void queuePacket(mesh::Packet*,bool) {++queued;}
@@ -100,3 +100,74 @@ lib_deps =
extends = Waveshare_S3_LR1121
build_src_filter = ${Waveshare_S3_LR1121.build_src_filter}
+<../examples/kiss_modem/>
[env:Waveshare_S3_LR1121_repeater_observer_mqtt]
extends = Waveshare_S3_LR1121
extra_scripts =
${esp32_base.extra_scripts}
pre:scripts/generate_cert_bundle.py
board_ssl_cert_source = adafruit-full
board_build.embed_files = src/certs/x509_crt_bundle.bin
build_flags =
${Waveshare_S3_LR1121.build_flags}
-D ADVERT_NAME='"MQTT Observer"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_BRIDGE=1
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
-D ESP32_CPU_FREQ=160
build_src_filter = ${Waveshare_S3_LR1121.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
+<../examples/simple_repeater>
lib_deps =
${Waveshare_S3_LR1121.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
[env:Waveshare_S3_LR1121_room_server_observer_mqtt]
extends = Waveshare_S3_LR1121
extra_scripts =
${esp32_base.extra_scripts}
pre:scripts/generate_cert_bundle.py
board_ssl_cert_source = adafruit-full
board_build.embed_files = src/certs/x509_crt_bundle.bin
build_flags =
${Waveshare_S3_LR1121.build_flags}
-D ADVERT_NAME='"Waveshare LR1121 Room Observer"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_BRIDGE=1
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
-D ESP32_CPU_FREQ=160
build_src_filter = ${Waveshare_S3_LR1121.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
+<../examples/simple_room_server>
lib_deps =
${Waveshare_S3_LR1121.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson @ 7.4.3
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
+4
View File
@@ -492,6 +492,8 @@ body.tab-cli{padding-bottom:0}
<span class="tgl"><input type="checkbox" data-k="mqtt.raw"><u></u></span></div>
<div class="sw"><span><b>Received packets</b><i>Report packets heard over the air</i></span>
<span class="tgl"><input type="checkbox" data-k="mqtt.rx"><u></u></span></div>
<div class="sw"><span><b>Drop bad advert signatures</b><i>Skip received adverts that fail verification. Off keeps RF corruption visible to collectors</i></span>
<span class="tgl"><input type="checkbox" data-k="mqtt.advert.verify"><u></u></span></div>
<div class="sw"><span><b>Publish neighbors</b><i>Periodic neighbor table and scopes (PSRAM boards only)</i></span>
<span class="tgl"><input type="checkbox" data-k="mqtt.neighbors"><u></u></span></div>
<div class="row" style="margin-top:6px">
@@ -839,6 +841,7 @@ function cfgVal(k){ // map a `set` key to its current value string, from st.cfg
case"mqtt.origin":return q.origin;case"mqtt.iata":return q.iata;
case"mqtt.status":return q.status?"on":"off";case"mqtt.packets":return q.packets?"on":"off";
case"mqtt.raw":return q.raw?"on":"off";case"mqtt.tx":return q.tx;case"mqtt.rx":return q.rx?"on":"off";
case"mqtt.advert.verify":return q.advert_verify?"on":"off";
case"mqtt.interval":return String(q.interval);
case"mqtt.neighbors":return q.neighbors?"on":"off";case"mqtt.neighbors.interval":return String(q.neighbors_interval);
case"mqtt.ntp":return q.ntp;
@@ -1960,6 +1963,7 @@ var CLI_KEYS=[
["mqtt.packets","Publish packet messages",0,"on|off"],
["mqtt.raw","Also publish full raw frames",0,"on|off"],
["mqtt.rx","Publish packets heard over the air",0,"on|off"],
["mqtt.advert.verify","Drop received adverts with bad signatures",0,"on|off"],
["mqtt.tx","Publish packets this node sends",0,"off|on|advert"],
["mqtt.interval","Status publish interval {1-60 min}",0],
["mqtt.neighbors","Publish the neighbour table (PSRAM boards)",0,"on|off"],