From 4716410bc22e26873d41e53f5f0277b848688360 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 7 Oct 2026 20:07:36 -0700 Subject: [PATCH 1/4] feat(mqtt): add opt-in advert signature check before publishing Received packets are uploaded before Mesh::onRecvPacket validates them, so an advert corrupted over the air that still passes the LoRa CRC is published with a mangled name or location. `set mqtt.advert.verify on` verifies each received advert's Ed25519 signature (same message layout as Mesh.cpp, including the 32-byte app-data clamp) and skips publishing failures. Defaults off so collectors keep seeing RF corruption on the mesh. Dropped adverts are counted as badsig= in `get mqtt.stats`. Exposed in the web config Publishing card and persisted as mqtt.advert_verify in prefs JSON. --- MQTT_IMPLEMENTATION.md | 2 + docs/cli_commands.md | 15 ++++ scripts/webconfig_mock_server.py | 5 +- src/helpers/AdvertSignature.h | 28 +++++++ src/helpers/CommonCLI_Observer.cpp | 6 ++ src/helpers/MQTTDefaults.h | 1 + src/helpers/MQTTPrefsSerializer.h | 10 ++- src/helpers/MQTTPrefsStorage.h | 4 + src/helpers/WebConfigKeys.h | 2 +- src/helpers/bridges/MQTTBridge.cpp | 17 ++++ src/helpers/bridges/MQTTBridge.h | 1 + src/helpers/esp32/WebConfigServer.cpp | 1 + .../test_advert_signature.cpp | 79 +++++++++++++++++++ .../test_mqtt_prefs_serializer.cpp | 37 +++++++++ webui/index.html | 4 + 15 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 src/helpers/AdvertSignature.h create mode 100644 test/test_advert_signature/test_advert_signature.cpp diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index c55e1ff96..b30d183a7 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -524,6 +524,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) @@ -540,6 +541,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) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index d85590d11..9cc24d307 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1230,6 +1230,21 @@ region save --- +#### View or change MQTT advert signature filtering +**Usage:** +- `get mqtt.advert.verify` +- `set mqtt.advert.verify ` + +**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. + +--- + #### View or change periodic neighbors publishing (MQTT observer, PSRAM only) **Usage:** - `get mqtt.neighbors` diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index e8f728eab..3a98cbaeb 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -117,7 +117,7 @@ def default_config(setup_mode): }, "mqtt": { "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", @@ -216,7 +216,8 @@ class State: 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"), + "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"), diff --git a/src/helpers/AdvertSignature.h b/src/helpers/AdvertSignature.h new file mode 100644 index 000000000..066763e09 --- /dev/null +++ b/src/helpers/AdvertSignature.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include + +// Signed-message layout of an ADVERT payload, mirroring Mesh::onRecvPacket: +// pub_key(32) + timestamp(4) + signature(64) + app_data, where only the first +// MAX_ADVERT_DATA_SIZE bytes of app_data are covered by the signature. +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 if the payload is short. +static inline int buildMessage(const uint8_t* payload, int payload_len, + uint8_t* out, const uint8_t** sig) { + if (payload == nullptr || payload_len < kHeaderLen) return -1; + int app_data_len = payload_len - kHeaderLen; + if (app_data_len > MAX_ADVERT_DATA_SIZE) app_data_len = MAX_ADVERT_DATA_SIZE; + 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 diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 0cee10c81..94518a85d 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -366,6 +366,10 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf _mqtt_prefs.mqtt_rx_enabled = memcmp(&config[8], "on", 2) == 0 ? 1 : 0; if (!persistObserverPrefs(reply)) return true; strcpy(reply, "OK"); + } else if (memcmp(config, "mqtt.advert.verify ", 19) == 0) { + _mqtt_prefs.mqtt_advert_verify = memcmp(&config[19], "on", 2) == 0 ? 1 : 0; + if (!persistObserverPrefs(reply)) return true; + strcpy(reply, "OK"); } else if (memcmp(config, "mqtt.interval ", 14) == 0) { uint32_t minutes = _atoi(&config[14]); if (minutes >= 1 && minutes <= 60) { @@ -931,6 +935,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 (memcmp(config, "mqtt.advert.verify", 18) == 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); diff --git a/src/helpers/MQTTDefaults.h b/src/helpers/MQTTDefaults.h index ecee15459..b8a6e3d2a 100644 --- a/src/helpers/MQTTDefaults.h +++ b/src/helpers/MQTTDefaults.h @@ -68,6 +68,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 = 1; diff --git a/src/helpers/MQTTPrefsSerializer.h b/src/helpers/MQTTPrefsSerializer.h index 7a772c269..ee7eefb86 100644 --- a/src/helpers/MQTTPrefsSerializer.h +++ b/src/helpers/MQTTPrefsSerializer.h @@ -223,10 +223,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; StatusPrefs _status; NeighborPrefs _neighbors; OwnerPrefs _owner; @@ -241,6 +241,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); def("status", _status); def("neighbors", _neighbors); def("owner", _owner); @@ -255,7 +256,8 @@ class MQTTPrefsSerializer : public ConfigSerializer { MqttPrefs(MQTTPrefs* prefs, const MQTTPrefs* defaults) : _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), _slot2(prefs, 1), _slot3(prefs, 2), _slot4(prefs, 3), _slot5(prefs, 4), _slot6(prefs, 5) { @@ -272,10 +274,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(_packets_enabled); _prefs->mqtt_raw_enabled = static_cast(_raw_enabled); _prefs->mqtt_tx_enabled = static_cast(_tx_enabled); _prefs->mqtt_rx_enabled = static_cast(_rx_enabled); + _prefs->mqtt_advert_verify = static_cast(_advert_verify); if (_prefs->mqtt_iata[0] != '\0') { if (!mqttIataValid(_prefs->mqtt_iata)) { memcpy(_prefs->mqtt_iata, _default_iata, sizeof(_default_iata)); diff --git a/src/helpers/MQTTPrefsStorage.h b/src/helpers/MQTTPrefsStorage.h index c5072f15c..59587915b 100644 --- a/src/helpers/MQTTPrefsStorage.h +++ b/src/helpers/MQTTPrefsStorage.h @@ -131,6 +131,10 @@ struct MQTTPrefs { // is configured over Ethernet and therefore may intentionally have no Wi-Fi // SSID. Existing Wi-Fi installations remain complete by the helper below. uint8_t network_setup_complete; + + // Drop received adverts whose Ed25519 signature fails before publishing. + // Defaults off so RF corruption on the mesh stays visible to collectors. + uint8_t mqtt_advert_verify; }; static inline bool mqttNetworkSetupComplete(const MQTTPrefs* prefs) { diff --git a/src/helpers/WebConfigKeys.h b/src/helpers/WebConfigKeys.h index c518be9ab..ed243049c 100644 --- a/src/helpers/WebConfigKeys.h +++ b/src/helpers/WebConfigKeys.h @@ -25,7 +25,7 @@ static const char* const WC_ALLOWED_SET_KEYS[] = { // 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.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", }; diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 8c24fbf2b..e71c24075 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -8,6 +8,7 @@ #include "../MQTTReplyFormat.h" #include "../MQTTRuntimeBufferLifecycle.h" #include "../MQTTTopicRouter.h" +#include "../AdvertSignature.h" #include "../TxtDataHelpers.h" #include #include @@ -373,6 +374,10 @@ void MQTTBridge::formatMqttStatsReply(char* buf, size_t bufsize) { if (b->_filtered_packets > 0) { replyAppendf(buf, bufsize, &pos, " filt=%lu", b->_filtered_packets); } + // badsig=: 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=: 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. @@ -3627,6 +3632,18 @@ void MQTTBridge::onPacketReceived(mesh::Packet *packet) { return; } + // Opt-in: drop adverts that fail verification (RF corruption that passed the + // LoRa CRC). Runs here, where Mesh::onRecvPacket already verifies on this stack. + 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); } diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 767a49e10..fa9433061 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -449,6 +449,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) diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index a898872be..f26c14fa5 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -964,6 +964,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; // CLI takes minutes mqtt["neighbors"] = (bool)_obs->mqtt_neighbors_enabled; mqtt["neighbors_interval"] = _obs->mqtt_neighbors_interval / 3600000UL; // CLI takes hours diff --git a/test/test_advert_signature/test_advert_signature.cpp b/test/test_advert_signature/test_advert_signature.cpp new file mode 100644 index 000000000..ad1325acd --- /dev/null +++ b/test/test_advert_signature/test_advert_signature.cpp @@ -0,0 +1,79 @@ +#include + +#include + +#include "helpers/AdvertSignature.h" + +namespace { + +std::vector fromHex(const char* hex) { + std::vector 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(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 payload = fromHex(kAdvertPayload); + uint8_t message[AdvertSignature::kMaxMessageLen]; + const uint8_t* sig = nullptr; + + int len = AdvertSignature::buildMessage(payload.data(), static_cast(payload.size()), + message, &sig); + + int app_data_len = static_cast(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(i); + uint8_t message[AdvertSignature::kMaxMessageLen]; + const uint8_t* sig = nullptr; + EXPECT_EQ(PUB_KEY_SIZE + 4, + AdvertSignature::buildMessage(payload, sizeof(payload), message, &sig)); +} + +// Mesh::onRecvPacket only signs the first MAX_ADVERT_DATA_SIZE bytes of app +// data; a longer tail must not change the message. +TEST(AdvertSignature, ClampsAppDataLikeMesh) { + uint8_t payload[AdvertSignature::kHeaderLen + MAX_ADVERT_DATA_SIZE + 10]; + for (size_t i = 0; i < sizeof(payload); i++) payload[i] = static_cast(i); + uint8_t message[AdvertSignature::kMaxMessageLen]; + const uint8_t* sig = nullptr; + + int len = AdvertSignature::buildMessage(payload, sizeof(payload), message, &sig); + + ASSERT_EQ(AdvertSignature::kMaxMessageLen, len); + EXPECT_EQ(0, memcmp(message + PUB_KEY_SIZE + 4, payload + AdvertSignature::kHeaderLen, + MAX_ADVERT_DATA_SIZE)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp b/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp index d94c658cd..a0b7a300d 100644 --- a/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp +++ b/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp @@ -439,6 +439,43 @@ TEST(MQTTPrefsSerializer, DisplayFlipRoundTripsAndRepairs) { EXPECT_EQ(0, prefs.display_flip); } +TEST(MQTTPrefsSerializer, AdvertVerifyDefaultsOffRoundTripsAndRepairs) { + MQTTPrefs source = defaults(); + EXPECT_EQ(0, source.mqtt_advert_verify); + source.mqtt_advert_verify = 1; + + OutputStream output; + MQTTPrefsSerializer writer(&source); + ASSERT_TRUE(writer.saveSerial(output)); + + MQTTPrefs loaded = defaults(); + InputStream input(output.text()); + MQTTPrefsSerializer reader(&loaded); + ASSERT_TRUE(reader.loadSerial(input)); + bool repaired = false; + ASSERT_TRUE(reader.apply(&repaired)); + EXPECT_FALSE(repaired); + EXPECT_EQ(1, loaded.mqtt_advert_verify); + + // A file written before the key existed keeps the off default. + MQTTPrefs older = defaults(); + InputStream legacy("{version:1,mqtt:{rx_enabled:1}}"); + MQTTPrefsSerializer legacy_serializer(&older); + ASSERT_TRUE(legacy_serializer.loadSerial(legacy)); + repaired = false; + ASSERT_TRUE(legacy_serializer.apply(&repaired)); + EXPECT_EQ(0, older.mqtt_advert_verify); + + MQTTPrefs prefs = defaults(); + InputStream bogus("{version:1,mqtt:{advert_verify:5}}"); + MQTTPrefsSerializer bogus_serializer(&prefs); + ASSERT_TRUE(bogus_serializer.loadSerial(bogus)); + repaired = false; + ASSERT_TRUE(bogus_serializer.apply(&repaired)); + EXPECT_TRUE(repaired); + EXPECT_EQ(0, prefs.mqtt_advert_verify); +} + TEST(MQTTPrefsSerializer, RepairsDisplayTimeoutOutOfRange) { MQTTPrefs prefs = defaults(); InputStream input("{version:1,display:{timeout_s:99999}}"); diff --git a/webui/index.html b/webui/index.html index d8dda96a4..d3d2bfd64 100644 --- a/webui/index.html +++ b/webui/index.html @@ -438,6 +438,8 @@ body.tab-cli{padding-bottom:0}
Received packetsReport packets heard over the air
+
Drop bad advert signaturesSkip received adverts that fail verification. Off keeps RF corruption visible to collectors +
Publish neighborsPeriodic neighbor table + scopes (PSRAM boards only)
@@ -712,6 +714,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; @@ -1573,6 +1576,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"], From 680c1eda66317e443bb03d6d44685be2add682ee Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 11 Oct 2026 20:51:18 -0700 Subject: [PATCH 2/4] feat(variants): add Waveshare ESP32-S3-LR1121 ESP32-S3FH4R2 (4MB flash, 2MB PSRAM, native USB-CDC) with an LR1121. Pins, the DIO5/DIO6 RF switch table and the 1.8 V TCXO follow the HF schematic and Waveshare's examples. Envs: repeater, room server, companion USB/BLE and KISS modem, all on min_spiffs so roles share one partition layout. Only RFO_HP_LF is wired (R4 on RFO_LP_LF is not fitted), so the new opt-in LR1121_FORCE_HP_PA flag keeps RadioLib on the HP PA at <= 14 dBm, where it would otherwise select the unconnected LP PA. --- src/helpers/radiolib/CustomLR1121.h | 7 ++ .../WaveshareS3LR1121Board.h | 11 ++ .../waveshare_esp32s3_lr1121/platformio.ini | 102 ++++++++++++++++++ variants/waveshare_esp32s3_lr1121/target.cpp | 63 +++++++++++ variants/waveshare_esp32s3_lr1121/target.h | 17 +++ 5 files changed, 200 insertions(+) create mode 100644 variants/waveshare_esp32s3_lr1121/WaveshareS3LR1121Board.h create mode 100644 variants/waveshare_esp32s3_lr1121/platformio.ini create mode 100644 variants/waveshare_esp32s3_lr1121/target.cpp create mode 100644 variants/waveshare_esp32s3_lr1121/target.h diff --git a/src/helpers/radiolib/CustomLR1121.h b/src/helpers/radiolib/CustomLR1121.h index 67e3b52a9..e8c6c431d 100644 --- a/src/helpers/radiolib/CustomLR1121.h +++ b/src/helpers/radiolib/CustomLR1121.h @@ -36,6 +36,13 @@ class CustomLR1121 : public LR1121 { return len; } +#ifdef LR1121_FORCE_HP_PA + // boards with only RFO_HP_LF wired: stop RadioLib switching to the LP PA at <= 14 dBm (2.4 GHz still uses the HF PA) + int16_t setOutputPower(int8_t power) override { + return LR1121::setOutputPower(power, true); + } +#endif + float getFreqMHz() const { return freqMHz; } int16_t setRxBoostedGainMode(bool en) { diff --git a/variants/waveshare_esp32s3_lr1121/WaveshareS3LR1121Board.h b/variants/waveshare_esp32s3_lr1121/WaveshareS3LR1121Board.h new file mode 100644 index 000000000..8307ba505 --- /dev/null +++ b/variants/waveshare_esp32s3_lr1121/WaveshareS3LR1121Board.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +class WaveshareS3LR1121Board : public ESP32Board { +public: + const char* getManufacturerName() const override { + return "Waveshare ESP32-S3-LR1121"; + } +}; diff --git a/variants/waveshare_esp32s3_lr1121/platformio.ini b/variants/waveshare_esp32s3_lr1121/platformio.ini new file mode 100644 index 000000000..8e7b52d4f --- /dev/null +++ b/variants/waveshare_esp32s3_lr1121/platformio.ini @@ -0,0 +1,102 @@ +; Waveshare ESP32-S3-LR1121 (HF: 850-930 MHz + 2.4 GHz) +; ESP32-S3FH4R2 (4MB flash, 2MB PSRAM), native USB-CDC via the Tiny-Adapter FPC. +; No user LED, battery sense or user button (BOOT is GPIO0). +[Waveshare_S3_LR1121] +extends = esp32_base +board = esp32-s3-zero +; min_spiffs: 1.9MB OTA app slots; one layout for every role so switching roles keeps the filesystem +board_build.partitions = min_spiffs.csv +build_flags = ${esp32_base.build_flags} + -I variants/waveshare_esp32s3_lr1121 + -D BOARD_HAS_PSRAM + -D P_LORA_DIO_1=38 + -D P_LORA_NSS=42 + -D P_LORA_RESET=39 + -D P_LORA_BUSY=41 + -D P_LORA_SCLK=40 + -D P_LORA_MISO=46 + -D P_LORA_MOSI=45 + -D RADIO_CLASS=CustomLR1121 + -D WRAPPER_CLASS=CustomLR1121Wrapper + -D LR11X0_DIO3_TCXO_VOLTAGE=1.8 + -D LR1121_FORCE_HP_PA ; RFO_LP_LF is not fitted (R4 NC) + -D RX_BOOSTED_GAIN=true + -D LORA_TX_POWER=22 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/waveshare_esp32s3_lr1121> +lib_deps = + ${esp32_base.lib_deps} + +[env:Waveshare_S3_LR1121_repeater] +extends = Waveshare_S3_LR1121 +build_flags = + ${Waveshare_S3_LR1121.build_flags} + -D ADVERT_NAME='"Waveshare LR1121 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Waveshare_S3_LR1121.build_src_filter} + +<../examples/simple_repeater/*.cpp> +lib_deps = + ${Waveshare_S3_LR1121.lib_deps} + ${esp32_ota.lib_deps} + +[env:Waveshare_S3_LR1121_room_server] +extends = Waveshare_S3_LR1121 +build_flags = + ${Waveshare_S3_LR1121.build_flags} + -D ADVERT_NAME='"Waveshare LR1121 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Waveshare_S3_LR1121.build_src_filter} + +<../examples/simple_room_server> +lib_deps = + ${Waveshare_S3_LR1121.lib_deps} + ${esp32_ota.lib_deps} + +[env:Waveshare_S3_LR1121_companion_radio_usb] +extends = Waveshare_S3_LR1121 +build_flags = + ${Waveshare_S3_LR1121.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Waveshare_S3_LR1121.build_src_filter} + + + +<../examples/companion_radio/*.cpp> +lib_deps = + ${Waveshare_S3_LR1121.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:Waveshare_S3_LR1121_companion_radio_ble] +extends = Waveshare_S3_LR1121 +build_flags = + ${Waveshare_S3_LR1121.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D BLE_PIN_CODE=123456 +; -D BLE_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Waveshare_S3_LR1121.build_src_filter} + + + +<../examples/companion_radio/*.cpp> +lib_deps = + ${Waveshare_S3_LR1121.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:Waveshare_S3_LR1121_kiss_modem] +extends = Waveshare_S3_LR1121 +build_src_filter = ${Waveshare_S3_LR1121.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/waveshare_esp32s3_lr1121/target.cpp b/variants/waveshare_esp32s3_lr1121/target.cpp new file mode 100644 index 000000000..56775c4ce --- /dev/null +++ b/variants/waveshare_esp32s3_lr1121/target.cpp @@ -0,0 +1,63 @@ +#include +#include "target.h" + +WaveshareS3LR1121Board board; + +static SPIClass spi; +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); +SensorManager sensors; + +#ifndef LORA_CR + #define LORA_CR 5 +#endif + +// DIO5/DIO6 drive the RTC6603SP sub-GHz T/R switch; the 2.4 GHz port (RFIO_HF) is unswitched +static const uint32_t rfswitch_dios[Module::RFSWITCH_MAX_PINS] = { + RADIOLIB_LR11X0_DIO5, + RADIOLIB_LR11X0_DIO6, + RADIOLIB_NC +}; + +static const Module::RfSwitchMode_t rfswitch_table[] = { + // mode DIO5 DIO6 + { LR11x0::MODE_STBY, {LOW, LOW}}, + { LR11x0::MODE_RX, {HIGH, LOW}}, + { LR11x0::MODE_TX, {LOW, HIGH}}, + { LR11x0::MODE_TX_HP, {LOW, HIGH}}, + { LR11x0::MODE_TX_HF, {LOW, LOW}}, + { LR11x0::MODE_GNSS, {LOW, LOW}}, + { LR11x0::MODE_WIFI, {LOW, LOW}}, + END_OF_MODE_TABLE, +}; + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + + spi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); + int status = radio.begin(LORA_FREQ, LORA_BW, LORA_SF, LORA_CR, RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, + LORA_TX_POWER, 16, LR11X0_DIO3_TCXO_VOLTAGE); + if (status != RADIOLIB_ERR_NONE) { + Serial.print("ERROR: radio init failed: "); + Serial.println(status); + return false; + } + + radio.setCRC(2); + radio.explicitHeader(); + radio.setRfSwitchTable(rfswitch_dios, rfswitch_table); +#ifdef RX_BOOSTED_GAIN + radio.setRxBoostedGainMode(RX_BOOSTED_GAIN); +#endif + + return true; +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} diff --git a/variants/waveshare_esp32s3_lr1121/target.h b/variants/waveshare_esp32s3_lr1121/target.h new file mode 100644 index 000000000..461509e51 --- /dev/null +++ b/variants/waveshare_esp32s3_lr1121/target.h @@ -0,0 +1,17 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include "WaveshareS3LR1121Board.h" + +extern WaveshareS3LR1121Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern SensorManager sensors; + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); From 707099feb78f8face98a2cf3563ee76e36bcba08 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 11 Oct 2026 17:46:58 -0700 Subject: [PATCH 3/4] feat(variants): add Waveshare S3 LR1121 MQTT observer envs --- .../waveshare_esp32s3_lr1121/platformio.ini | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/variants/waveshare_esp32s3_lr1121/platformio.ini b/variants/waveshare_esp32s3_lr1121/platformio.ini index 8e7b52d4f..f53dad71c 100644 --- a/variants/waveshare_esp32s3_lr1121/platformio.ini +++ b/variants/waveshare_esp32s3_lr1121/platformio.ini @@ -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} + + + + + + + +<../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} + + + + + + + +<../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 From f44fcf4864d92357448b37f9ed4e46beb7a00c7d Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 11 Oct 2026 20:53:23 -0700 Subject: [PATCH 4/4] fix(radiolib): keep LR1121 tx power valid across sub-GHz and 2.4 GHz - Clip requested power to the active PA's range instead of failing. A stored 22 dBm was rejected at 2.4 GHz (HF PA max 13 dBm), and since setTxPower ignores errors the sub-GHz PA stayed configured. - Re-apply the requested power after every successful retune: PA choice depends on the band, and set radio / tempradio only call setParams. --- src/helpers/radiolib/CustomLR1121.h | 30 +++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/helpers/radiolib/CustomLR1121.h b/src/helpers/radiolib/CustomLR1121.h index e8c6c431d..7182d1d5c 100644 --- a/src/helpers/radiolib/CustomLR1121.h +++ b/src/helpers/radiolib/CustomLR1121.h @@ -9,6 +9,8 @@ class CustomLR1121 : public LR1121 { uint32_t _activityAt = 0; bool _headerSeen = false; bool _rx_boosted = false; + int8_t _txPower = 0; + bool _txPowerSet = false; public: CustomLR1121(Module *mod) : LR1121(mod) { } @@ -36,12 +38,32 @@ class CustomLR1121 : public LR1121 { return len; } -#ifdef LR1121_FORCE_HP_PA - // boards with only RFO_HP_LF wired: stop RadioLib switching to the LP PA at <= 14 dBm (2.4 GHz still uses the HF PA) + using LR1121::setOutputPower; + using LR1121::setFrequency; + int16_t setOutputPower(int8_t power) override { - return LR1121::setOutputPower(power, true); - } +#ifdef LR1121_FORCE_HP_PA + const bool force_hp = true; // boards with only RFO_HP_LF wired: never select the LP PA +#else + const bool force_hp = false; #endif + // clip to the active PA's range (HF PA tops out at 13 dBm) rather than fail and keep the other band's PA + int8_t clipped = power; + checkOutputPower(power, &clipped, force_hp); + int16_t state = LR1121::setOutputPower(clipped, force_hp); + if (state == RADIOLIB_ERR_NONE) { + _txPower = power; + _txPowerSet = true; + } + return state; + } + + int16_t setFrequency(float freq) override { + int16_t state = LR1121::setFrequency(freq); + // PA selection depends on the band, and set radio / tempradio don't call setTxPower + if (state == RADIOLIB_ERR_NONE && _txPowerSet) state = setOutputPower(_txPower); + return state; + } float getFreqMHz() const { return freqMHz; }