From 6ad3e5224e07f4c47942eff4fec6ed01f7ebf8b6 Mon Sep 17 00:00:00 2001 From: Jared Dohrman Date: Fri, 26 Jun 2026 10:29:38 +1000 Subject: [PATCH] feat(mqtt): add Waev broker Add Waev (mqtt.waev.app) as a curated MQTT broker, selectable from the web-panel Primary/Secondary dropdowns and the CLI. - Broker spec on bit 0x20, audience mqtt.waev.app, Google Trust Services WE1 CA (matches Waev's cert chain); widen broker mask/count to 0x3F/6 - Per-broker JWT lifetime via BrokerSpec.token_ttl_secs: Waev enforces a 1-hour max token TTL and rejects the 6h default with MQTT CONNACK 5, so it mints a 3600s token while other brokers keep the 6h default - CLI get/set mqtt.waev, web-panel dropdown entry + hidden toggle, docs - Log token TTL in the "token ready" line for broker auth debugging fix(web): make Custom MQTT settings full width when Custom is selected Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + eastmesh-docs/custom-cli.md | 3 +++ examples/simple_repeater/MyMesh.cpp | 8 ++++++++ src/helpers/mqtt/MQTTPrefs.cpp | 2 +- src/helpers/mqtt/MQTTUplink.cpp | 23 ++++++++++++++--------- src/helpers/mqtt/MQTTUplink.h | 8 ++++++-- src/helpers/web/WebPanelServer.cpp | 5 ++++- 7 files changed, 37 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ecbbed2f..dcadc9a3 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ uv run --group docs zensical build - curated broker support for: - `eastmesh-au` - `meshmapper` + - `waev` - `letsmesh-eu` (retired) - `letsmesh-us` (retired) - WSS transport at `/mqtt` diff --git a/eastmesh-docs/custom-cli.md b/eastmesh-docs/custom-cli.md index 04c230cf..3a6d1242 100644 --- a/eastmesh-docs/custom-cli.md +++ b/eastmesh-docs/custom-cli.md @@ -67,6 +67,8 @@ No-argument `get` commands must be entered exactly as shown. - `set mqtt.eastmesh-au on|off` - `get mqtt.meshmapper` - `set mqtt.meshmapper on|off` +- `get mqtt.waev` +- `set mqtt.waev on|off` - `get mqtt.letsmesh-eu` (retired) - `set mqtt.letsmesh-eu on|off` (retired) - `get mqtt.letsmesh-us` (retired) @@ -89,6 +91,7 @@ Notes: - new observer installs default `mqtt.iata` to `UNSET` - a maximum of two MQTT brokers can be enabled at once - `meshmapper` is the curated global broker (`wss://mqtt.meshmapper.net:443/mqtt`); like `eastmesh-au` it uses WSS, verified TLS, and MeshCore JWT auth with the broker host as the token audience +- `waev` is a curated broker (`wss://mqtt.waev.app:443/mqtt`); like `eastmesh-au` it uses WSS, verified TLS, and MeshCore JWT auth with the broker host as the token audience, but enforces a 1-hour auth token lifetime (the firmware mints a shorter-lived token for it automatically) - `letsmesh-eu` and `letsmesh-us` are retired (LetsMesh is no longer maintained) and off by default; any saved selections are cleared once on upgrade, though the endpoints stay selectable for legacy use - if `mqtt.iata` is `UNSET`, enabled MQTT brokers will not connect - custom MQTT uses the configured username and password, not JWT authentication diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 9996b884..f3e15e37 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -2446,6 +2446,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x01) ? "on" : "off"); } else if (strcmp(command, "get mqtt.meshmapper") == 0) { sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x10) ? "on" : "off"); + } else if (strcmp(command, "get mqtt.waev") == 0) { + sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x20) ? "on" : "off"); } else if (strcmp(command, "get mqtt.letsmesh-eu") == 0 || strcmp(command, "get mqtt.letsmesh.eu") == 0) { sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x02) ? "on" : "off"); } else if (strcmp(command, "get mqtt.letsmesh-us") == 0 || strcmp(command, "get mqtt.letsmesh.us") == 0) { @@ -2504,6 +2506,12 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply } else { strcpy(reply, "Err - max 2 mqtt brokers"); } + } else if (memcmp(command, "set mqtt.waev ", 14) == 0) { + if (mqtt.setEndpointEnabled(0x20, memcmp(&command[14], "on", 2) == 0)) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - max 2 mqtt brokers"); + } } else if (memcmp(command, "set mqtt.letsmesh-eu ", 21) == 0 || memcmp(command, "set mqtt.letsmesh.eu ", 21) == 0) { if (mqtt.setEndpointEnabled(0x02, memcmp(&command[21], "on", 2) == 0)) { strcpy(reply, "OK"); diff --git a/src/helpers/mqtt/MQTTPrefs.cpp b/src/helpers/mqtt/MQTTPrefs.cpp index c276d8db..372e25fc 100644 --- a/src/helpers/mqtt/MQTTPrefs.cpp +++ b/src/helpers/mqtt/MQTTPrefs.cpp @@ -75,7 +75,7 @@ bool MQTTPrefsStore::load(FILESYSTEM* fs, MQTTPrefs& prefs) { prefs.custom_transport = 0; } prefs.status_interval_ms = kFixedStatusIntervalMs; - prefs.enabled_mask &= 0x1F; + prefs.enabled_mask &= 0x3F; if (!prefs.brokers_migrated) { // LetsMesh is retired. Clear any saved EU/US selections once so dead brokers // stop retrying; the endpoints remain re-selectable afterwards. diff --git a/src/helpers/mqtt/MQTTUplink.cpp b/src/helpers/mqtt/MQTTUplink.cpp index 352c58ce..74c60438 100644 --- a/src/helpers/mqtt/MQTTUplink.cpp +++ b/src/helpers/mqtt/MQTTUplink.cpp @@ -128,14 +128,16 @@ void logMqttMemorySnapshot(const char*, const char* = nullptr) { } const MQTTUplink::BrokerSpec MQTTUplink::kBrokerSpecs[kBrokerCount] = { - {"eastmesh-au", "eastmesh-au", "mqtt2.eastmesh.au", "wss://mqtt2.eastmesh.au:443/mqtt", kEastmeshBit, false}, - {"meshmapper", "meshmapper", "mqtt.meshmapper.net", "wss://mqtt.meshmapper.net:443/mqtt", kMeshmapperBit, false}, + {"eastmesh-au", "eastmesh-au", "mqtt2.eastmesh.au", "wss://mqtt2.eastmesh.au:443/mqtt", kEastmeshBit, false, 0}, + {"meshmapper", "meshmapper", "mqtt.meshmapper.net", "wss://mqtt.meshmapper.net:443/mqtt", kMeshmapperBit, false, 0}, + // waev enforces a shorter max token lifetime than the curated default; match its documented config. + {"waev", "waev", "mqtt.waev.app", "wss://mqtt.waev.app:443/mqtt", kWaevBit, false, 3600}, // Retired: LetsMesh is no longer maintained. Kept selectable for legacy nodes; new nodes should use meshmapper. {"letsmesh-eu", "letsmesh-eu", "mqtt-eu-v1.letsmesh.net", "wss://mqtt-eu-v1.letsmesh.net:443/mqtt", - kLetsmeshEuBit, false}, + kLetsmeshEuBit, false, 0}, {"letsmesh-us", "letsmesh-us", "mqtt-us-v1.letsmesh.net", "wss://mqtt-us-v1.letsmesh.net:443/mqtt", - kLetsmeshUsBit, false}, - {"custom", "custom", nullptr, nullptr, kCustomBit, true}, + kLetsmeshUsBit, false, 0}, + {"custom", "custom", nullptr, nullptr, kCustomBit, true, 0}, }; MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity) @@ -202,7 +204,8 @@ const char* MQTTUplink::brokerCaCert(const BrokerSpec& spec) { if (spec.bit == kEastmeshBit || spec.bit == kMeshmapperBit) { return mqtt_ca_certs::kEastmeshIsrgRootX1Pem; // Let's Encrypt ISRG Root X1 } - return mqtt_ca_certs::kLetsmeshWe1Pem; + // waev (mqtt.waev.app) presents a Google Trust Services WE1 chain, same issuer as LetsMesh. + return mqtt_ca_certs::kLetsmeshWe1Pem; // Google Trust Services WE1 } const char* MQTTUplink::brokerHost(const BrokerState& broker) const { @@ -572,7 +575,8 @@ bool MQTTUplink::refreshToken(BrokerState& broker) { } } - time_t expires_at = now + kTokenLifetimeSecs; + time_t ttl = broker.spec->token_ttl_secs != 0 ? static_cast(broker.spec->token_ttl_secs) : kTokenLifetimeSecs; + time_t expires_at = now + ttl; const char* owner = _prefs.owner_public_key[0] ? _prefs.owner_public_key : nullptr; const char* email = _prefs.owner_email[0] ? _prefs.owner_email : nullptr; if (!JWTHelper::createAuthToken(*_identity, brokerHost(broker), now, expires_at, broker.token, kBrokerTokenSize, @@ -583,8 +587,9 @@ bool MQTTUplink::refreshToken(BrokerState& broker) { return false; } broker.token_expires_at = expires_at; - MQTT_LOG("%s token ready exp=%lu owner=%s email=%s", broker.spec->label, - static_cast(expires_at), owner != nullptr ? "yes" : "no", email != nullptr ? "yes" : "no"); + MQTT_LOG("%s token ready exp=%lu ttl=%ld owner=%s email=%s", broker.spec->label, + static_cast(expires_at), static_cast(ttl), + owner != nullptr ? "yes" : "no", email != nullptr ? "yes" : "no"); return true; } diff --git a/src/helpers/mqtt/MQTTUplink.h b/src/helpers/mqtt/MQTTUplink.h index 0f106004..82f421fb 100644 --- a/src/helpers/mqtt/MQTTUplink.h +++ b/src/helpers/mqtt/MQTTUplink.h @@ -89,6 +89,9 @@ private: const char* uri; uint8_t bit; bool custom; + // Per-broker JWT lifetime in seconds; 0 = use the default kTokenLifetimeSecs. waev enforces a + // shorter max token lifetime than the curated default, so it gets its own (matches its config). + uint32_t token_ttl_secs; }; struct BrokerState { @@ -131,9 +134,10 @@ private: static constexpr uint8_t kLetsmeshUsBit = 0x04; static constexpr uint8_t kCustomBit = 0x08; static constexpr uint8_t kMeshmapperBit = 0x10; - static constexpr uint8_t kBrokerMask = 0x1F; + static constexpr uint8_t kWaevBit = 0x20; + static constexpr uint8_t kBrokerMask = 0x3F; static constexpr uint8_t kMaxEnabledBrokers = 2; - static constexpr size_t kBrokerCount = 5; + static constexpr size_t kBrokerCount = 6; static const BrokerSpec kBrokerSpecs[kBrokerCount]; static bool isUnsetIataValue(const char* iata); static const char* brokerCaCert(const BrokerSpec& spec); diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index c58c7cbc..2e22029f 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -977,12 +977,13 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( +