From 9bce46c298e5ac8482f8369de5d6679fe829ed82 Mon Sep 17 00:00:00 2001 From: Jared Dohrman Date: Tue, 9 Jun 2026 19:09:33 +1000 Subject: [PATCH] feat: add custom MQTT WSS transport --- eastmesh-docs/custom-cli.md | 8 ++- eastmesh-docs/web-panel.md | 5 +- examples/simple_repeater/MyMesh.cpp | 8 +++ release-notes.yml | 48 +++++++++++++ src/helpers/mqtt/MQTTPrefs.cpp | 3 + src/helpers/mqtt/MQTTPrefs.h | 2 +- src/helpers/mqtt/MQTTUplink.cpp | 104 ++++++++++++++++++++++++---- src/helpers/mqtt/MQTTUplink.h | 5 ++ src/helpers/web/WebPanelServer.cpp | 45 ++++++++++++ 9 files changed, 208 insertions(+), 20 deletions(-) diff --git a/eastmesh-docs/custom-cli.md b/eastmesh-docs/custom-cli.md index ca777c08..7f6ed067 100644 --- a/eastmesh-docs/custom-cli.md +++ b/eastmesh-docs/custom-cli.md @@ -34,7 +34,7 @@ No-argument `get` commands must be entered exactly as shown. ### MQTT Status And Routing -- `get mqtt.status`: shows Wi-Fi, NTP, IATA, endpoint status, status publishing state, and TX state. +- `get mqtt.status`: shows Wi-Fi, NTP, IATA, endpoint status, custom endpoint transport, status publishing state, and TX state. - `get mqtt.statuscfg`: shows whether periodic status messages are enabled as a simple `on` or `off` value. Most users can just use `get mqtt.status`. - `get mqtt.client_version`: shows the MQTT `client_version` string published by the repeater. - `get mqtt.client_env`: shows the PlatformIO env used to build the repeater firmware. @@ -75,6 +75,8 @@ No-argument `get` commands must be entered exactly as shown. - `set mqtt.custom.host ` - `get mqtt.custom.port` - `set mqtt.custom.port ` +- `get mqtt.custom.transport`: shows `tcp` or `wss`. +- `set mqtt.custom.transport tcp|wss` - `get mqtt.custom.username` - `set mqtt.custom.username ` - `get mqtt.custom.password`: shows `set` when a custom password is configured. @@ -85,7 +87,9 @@ Notes: - new observer installs default `mqtt.iata` to `UNSET` - `letsmesh-eu` and `letsmesh-us` remain off by default unless already configured in saved prefs - if `mqtt.iata` is `UNSET`, enabled MQTT brokers will not connect -- custom MQTT uses normal MQTT over TCP with the configured username and password, not JWT authentication +- custom MQTT uses the configured username and password, not JWT authentication +- custom MQTT defaults to TCP; set `mqtt.custom.transport wss` for MQTT over secure WebSockets using the fixed `/mqtt` websocket path and the ESP-IDF x509 root CA bundle +- in `get mqtt.status`, custom MQTT is shown as `custom::`, for example `custom:wss:up`; `conn` means connecting and `up` means connected - custom MQTT uses the same `meshcore///` topics as the curated brokers - turning off a connected broker publishes a retained MQTT status update with `"status":"offline"` before the client disconnects - changing `mqtt.iata` away from a configured value also publishes retained offline status to the old status topic, restarts connected broker clients, and reconnects under the new topic path diff --git a/eastmesh-docs/web-panel.md b/eastmesh-docs/web-panel.md index 32dec770..7d751929 100644 --- a/eastmesh-docs/web-panel.md +++ b/eastmesh-docs/web-panel.md @@ -254,7 +254,7 @@ This section includes: - `mqtt.owner`: owner public key. - `mqtt.email`: owner contact email. - MQTT server toggles: `eastmesh-au`, `letsmesh-eu`, `letsmesh-us`, and custom MQTT. -- custom MQTT `host:port`, username, and password fields. +- custom MQTT `host:port`, TCP/WSS transport, username, and password fields. `UNSET - To be configured` is the default for new observer installs until a real saved value exists. @@ -264,7 +264,8 @@ Notes: - while `mqtt.iata` is `UNSET`, enabled MQTT brokers do not attempt to connect - the current MQTT server states are loaded when the page opens - you can toggle each MQTT server on or off from this panel -- custom MQTT uses normal MQTT over TCP with the configured username and password, not JWT authentication +- custom MQTT uses the configured username and password, not JWT authentication +- custom MQTT defaults to TCP; choose WSS for MQTT over secure WebSockets using the fixed `/mqtt` websocket path and the ESP-IDF x509 root CA bundle - turning off a connected MQTT server publishes retained offline status before the client disconnects - changing `mqtt.iata` away from a configured value publishes retained offline status to the old status topic, restarts connected broker clients, and reconnects under the new topic path - at most two MQTT brokers can be enabled at once diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 2c81407c..941d6e8c 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -2430,6 +2430,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply sprintf(reply, "> %s", mqtt.getCustomHost()[0] ? mqtt.getCustomHost() : "-"); } else if (strcmp(command, "get mqtt.custom.port") == 0) { sprintf(reply, "> %u", static_cast(mqtt.getCustomPort())); + } else if (strcmp(command, "get mqtt.custom.transport") == 0) { + sprintf(reply, "> %s", mqtt.getCustomTransport()); } else if (strcmp(command, "get mqtt.custom.username") == 0) { sprintf(reply, "> %s", mqtt.getCustomUsername()[0] ? mqtt.getCustomUsername() : "-"); } else if (strcmp(command, "get mqtt.custom.password") == 0) { @@ -2500,6 +2502,12 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply } else { strcpy(reply, "Err - bad mqtt.custom.port"); } + } else if (memcmp(command, "set mqtt.custom.transport ", 26) == 0) { + if (mqtt.setCustomTransport(&command[26])) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - bad mqtt.custom.transport"); + } } else if (memcmp(command, "set mqtt.custom.username ", 25) == 0) { if (mqtt.setCustomUsername(&command[25])) { strcpy(reply, "OK"); diff --git a/release-notes.yml b/release-notes.yml index c7fe8fe1..b98516a4 100644 --- a/release-notes.yml +++ b/release-notes.yml @@ -188,6 +188,30 @@ releases: text: "Hardened OTA startup by binding before reporting success and falling back to alternate ports when port 80 is still being released." breaking_changes: [] + - track: observer-eastmesh + version: "2026.6.3" + tag: "observer-eastmesh-v2026.6.3" + date: "2026-06-09" + previous_version: "2026.6.2" + summary: "Adds secure WebSocket transport support for custom MQTT brokers from the Observer web panel and CLI." + changes: + - type: added + area: mqtt + text: "Added TCP/WSS transport selection for custom MQTT brokers while keeping custom broker authentication to username and password only." + - type: fixed + area: mqtt + text: "Verified custom MQTT WSS connections with the ESP-IDF x509 root CA bundle and the fixed `/mqtt` WebSocket path." + - type: fixed + area: mqtt + text: "Configured custom MQTT WSS using a full `wss://host:port/mqtt` URI so ESP-MQTT handles the WebSocket connection consistently." + - type: changed + area: cli + text: "Updated `get mqtt.status` to show the custom MQTT transport and state, for example `custom:wss:up`." + - type: docs + area: docs + text: "Documented custom MQTT TCP/WSS configuration in the web panel and custom CLI guides." + breaking_changes: [] + - track: observer-eastmesh-bridge-espnow version: "2026.5.1" tag: "observer-eastmesh-bridge-espnow-v2026.5.1" @@ -287,3 +311,27 @@ releases: area: web text: "Hardened OTA startup by binding before reporting success and falling back to alternate ports when port 80 is still being released." breaking_changes: [] + + - track: observer-eastmesh-bridge-espnow + version: "2026.6.3" + tag: "observer-eastmesh-bridge-espnow-v2026.6.3" + date: "2026-06-09" + previous_version: "2026.6.2" + summary: "Adds secure WebSocket transport support for custom MQTT brokers in Observer ESP-NOW bridge builds." + changes: + - type: added + area: mqtt + text: "Added TCP/WSS transport selection for custom MQTT brokers while keeping custom broker authentication to username and password only." + - type: fixed + area: mqtt + text: "Verified custom MQTT WSS connections with the ESP-IDF x509 root CA bundle and the fixed `/mqtt` WebSocket path." + - type: fixed + area: mqtt + text: "Configured custom MQTT WSS using a full `wss://host:port/mqtt` URI so ESP-MQTT handles the WebSocket connection consistently." + - type: changed + area: cli + text: "Updated `get mqtt.status` to show the custom MQTT transport and state, for example `custom:wss:up`." + - type: docs + area: docs + text: "Documented custom MQTT TCP/WSS configuration in the web panel and custom CLI guides." + breaking_changes: [] diff --git a/src/helpers/mqtt/MQTTPrefs.cpp b/src/helpers/mqtt/MQTTPrefs.cpp index ad9b5436..10d43f54 100644 --- a/src/helpers/mqtt/MQTTPrefs.cpp +++ b/src/helpers/mqtt/MQTTPrefs.cpp @@ -70,6 +70,9 @@ bool MQTTPrefsStore::load(FILESYSTEM* fs, MQTTPrefs& prefs) { if (prefs.custom_port == 0) { prefs.custom_port = 1883; } + if (prefs.custom_transport > 1) { + prefs.custom_transport = 0; + } prefs.status_interval_ms = kFixedStatusIntervalMs; prefs.enabled_mask &= 0x0F; return true; diff --git a/src/helpers/mqtt/MQTTPrefs.h b/src/helpers/mqtt/MQTTPrefs.h index bfad8d2e..f6a3604b 100644 --- a/src/helpers/mqtt/MQTTPrefs.h +++ b/src/helpers/mqtt/MQTTPrefs.h @@ -33,7 +33,7 @@ struct MQTTPrefs { char custom_host[96]; char custom_username[65]; char custom_password[96]; - uint8_t reserved[1]; + uint8_t custom_transport; }; class MQTTPrefsStore { diff --git a/src/helpers/mqtt/MQTTUplink.cpp b/src/helpers/mqtt/MQTTUplink.cpp index 2b39d900..cec9500a 100644 --- a/src/helpers/mqtt/MQTTUplink.cpp +++ b/src/helpers/mqtt/MQTTUplink.cpp @@ -11,8 +11,9 @@ #include #include #include -#if defined(CONFIG_MBEDTLS_CERTIFICATE_BUNDLE) && !(defined(ARDUINO) && ESP_IDF_VERSION_MAJOR < 5) -#include +#if defined(CONFIG_MBEDTLS_CERTIFICATE_BUNDLE) +#include +extern "C" esp_err_t esp_crt_bundle_attach(void* conf); #define MQTT_USE_CRT_BUNDLE 1 #define MQTT_CRT_BUNDLE_ATTACH esp_crt_bundle_attach #endif @@ -214,6 +215,30 @@ uint16_t MQTTUplink::brokerPort(const BrokerState& broker) const { return 443; } +bool MQTTUplink::brokerUsesWss(const BrokerState& broker) const { + if (broker.spec == nullptr) { + return false; + } + return !broker.spec->custom || _prefs.custom_transport == 1; +} + +const char* MQTTUplink::brokerUri(BrokerState& broker) const { + if (broker.spec == nullptr) { + return ""; + } + if (broker.spec->uri != nullptr) { + return broker.spec->uri; + } + if (brokerUsesWss(broker)) { + snprintf(broker.uri, sizeof(broker.uri), "wss://%s:%u/mqtt", brokerHost(broker), + static_cast(brokerPort(broker))); + } else { + snprintf(broker.uri, sizeof(broker.uri), "mqtt://%s:%u", brokerHost(broker), + static_cast(brokerPort(broker))); + } + return broker.uri; +} + bool MQTTUplink::isBrokerConfigured(const BrokerState& broker) const { if (broker.spec == nullptr) { return false; @@ -930,19 +955,27 @@ void MQTTUplink::ensureBroker(BrokerState& broker, bool allow_new_connect) { } refreshBrokerState(broker); - MQTT_LOG("%s mqtt init host=%s port=%u path=%s client_id=%s", broker.spec->label, brokerHost(broker), - static_cast(brokerPort(broker)), broker.spec->custom ? "-" : "/mqtt", broker.client_id); + const char* uri = brokerUsesWss(broker) ? brokerUri(broker) : nullptr; + MQTT_LOG("%s mqtt init host=%s port=%u transport=%s path=%s uri=%s client_id=%s", broker.spec->label, brokerHost(broker), + static_cast(brokerPort(broker)), brokerUsesWss(broker) ? "wss" : "tcp", + brokerUsesWss(broker) ? "/mqtt" : "-", uri != nullptr ? uri : "-", broker.client_id); logMqttMemorySnapshot("init-pre", broker.spec->label); esp_mqtt_client_config_t cfg = {}; #if ESP_IDF_VERSION_MAJOR >= 5 - cfg.broker.address.hostname = brokerHost(broker); - cfg.broker.address.port = brokerPort(broker); - cfg.broker.address.transport = broker.spec->custom ? MQTT_TRANSPORT_OVER_TCP : MQTT_TRANSPORT_OVER_WSS; - if (!broker.spec->custom) { - cfg.broker.address.path = "/mqtt"; + if (brokerUsesWss(broker)) { + cfg.broker.address.uri = uri; + } else { + cfg.broker.address.hostname = brokerHost(broker); + cfg.broker.address.port = brokerPort(broker); + cfg.broker.address.transport = MQTT_TRANSPORT_OVER_TCP; } if (broker.spec->custom) { cfg.credentials.authentication.password = _prefs.custom_password; + if (brokerUsesWss(broker)) { +#if defined(MQTT_USE_CRT_BUNDLE) + cfg.broker.verification.crt_bundle_attach = MQTT_CRT_BUNDLE_ATTACH; +#endif + } } else { #if defined(MQTT_USE_CRT_BUNDLE) cfg.broker.verification.crt_bundle_attach = MQTT_CRT_BUNDLE_ATTACH; @@ -964,8 +997,13 @@ void MQTTUplink::ensureBroker(BrokerState& broker, bool allow_new_connect) { cfg.buffer.size = 768; cfg.buffer.out_size = 1280; #else - cfg.host = brokerHost(broker); - cfg.port = brokerPort(broker); + if (brokerUsesWss(broker)) { + cfg.uri = uri; + } else { + cfg.host = brokerHost(broker); + cfg.port = brokerPort(broker); + cfg.transport = MQTT_TRANSPORT_OVER_TCP; + } cfg.username = broker.username; cfg.password = broker.spec->custom ? _prefs.custom_password : broker.token; cfg.client_id = broker.client_id; @@ -975,14 +1013,17 @@ void MQTTUplink::ensureBroker(BrokerState& broker, bool allow_new_connect) { cfg.reconnect_timeout_ms = 10000; cfg.network_timeout_ms = 10000; cfg.disable_auto_reconnect = true; - cfg.transport = broker.spec->custom ? MQTT_TRANSPORT_OVER_TCP : MQTT_TRANSPORT_OVER_WSS; + if (broker.spec->custom && brokerUsesWss(broker)) { +#if defined(MQTT_USE_CRT_BUNDLE) + cfg.crt_bundle_attach = MQTT_CRT_BUNDLE_ATTACH; +#endif + } if (!broker.spec->custom) { #if defined(MQTT_USE_CRT_BUNDLE) cfg.crt_bundle_attach = MQTT_CRT_BUNDLE_ATTACH; #else cfg.cert_pem = brokerCaCert(*broker.spec); #endif - cfg.path = "/mqtt"; } cfg.lwt_topic = broker.status_topic; cfg.lwt_msg = broker.offline_payload; @@ -1163,12 +1204,13 @@ void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const { }; snprintf(reply, reply_size, - "> wifi:%s ntp:%s iata:%s eastmesh-au:%s letsmesh-eu:%s letsmesh-us:%s custom:%s status:%s tx:%s", + "> wifi:%s ntp:%s iata:%s eastmesh-au:%s letsmesh-eu:%s letsmesh-us:%s custom:%s:%s status:%s tx:%s", (_network != nullptr && _network->isWifiConnected()) ? "up" : "down", (_network != nullptr && _network->hasTimeSync()) ? "up" : "wait", _prefs.iata, broker_state(kEastmeshBit), broker_state(kLetsmeshEuBit), broker_state(kLetsmeshUsBit), - broker_state(kCustomBit), _prefs.status_enabled ? "on" : "off", _prefs.tx_enabled ? "on" : "off"); + getCustomTransport(), broker_state(kCustomBit), _prefs.status_enabled ? "on" : "off", + _prefs.tx_enabled ? "on" : "off"); } bool MQTTUplink::setEndpointEnabled(uint8_t bit, bool enabled) { @@ -1335,6 +1377,38 @@ bool MQTTUplink::setCustomPort(const char* port) { return saved; } +const char* MQTTUplink::getCustomTransport() const { + return _prefs.custom_transport == 1 ? "wss" : "tcp"; +} + +bool MQTTUplink::setCustomTransport(const char* transport) { + if (transport == nullptr) { + return false; + } + uint8_t parsed; + if (strcasecmp(transport, "tcp") == 0) { + parsed = 0; + } else if (strcasecmp(transport, "wss") == 0) { +#if !defined(MQTT_USE_CRT_BUNDLE) + return false; +#endif + parsed = 1; + } else { + return false; + } + bool changed = _prefs.custom_transport != parsed; + _prefs.custom_transport = parsed; + bool saved = savePrefs(); + if (saved && changed) { + for (BrokerState& broker : _brokers) { + if (broker.spec != nullptr && broker.spec->bit == kCustomBit) { + destroyBroker(broker); + } + } + } + return saved; +} + bool MQTTUplink::setCustomUsername(const char* username) { if (username == nullptr) { return false; diff --git a/src/helpers/mqtt/MQTTUplink.h b/src/helpers/mqtt/MQTTUplink.h index f0c00191..2fd112ec 100644 --- a/src/helpers/mqtt/MQTTUplink.h +++ b/src/helpers/mqtt/MQTTUplink.h @@ -67,6 +67,8 @@ public: const char* getCustomHost() const { return _prefs.custom_host; } bool setCustomPort(const char* port); uint16_t getCustomPort() const { return _prefs.custom_port; } + bool setCustomTransport(const char* transport); + const char* getCustomTransport() const; bool setCustomUsername(const char* username); const char* getCustomUsername() const { return _prefs.custom_username; } bool setCustomPassword(const char* password); @@ -104,6 +106,7 @@ private: char username[70]; char* token; char client_id[48]; + char uri[144]; char status_topic[128]; char offline_payload[512]; }; @@ -144,6 +147,8 @@ private: bool preflightBroker(BrokerState& broker) const; const char* brokerHost(const BrokerState& broker) const; uint16_t brokerPort(const BrokerState& broker) const; + bool brokerUsesWss(const BrokerState& broker) const; + const char* brokerUri(BrokerState& broker) const; bool isBrokerConfigured(const BrokerState& broker) const; void formatTopic(char* dst, size_t dst_size, const char* leaf) const; void refreshIdentityStrings(); diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index b41d22a3..9ee93293 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -1042,6 +1042,16 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( +
+ +
+ + +
+
@@ -2841,6 +2851,30 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( input.value = host && host !== "-" ? `${host}:${port}` : ""; } } + function refreshCustomTransportUi(transport) { + const mode = transport === "wss" ? "wss" : "tcp"; + const slider = document.getElementById("mqttCustomTransport"); + if (slider) { + slider.value = mode === "wss" ? "1" : "0"; + } + document.querySelectorAll("[data-custom-transport-label]").forEach((label) => { + label.classList.toggle("active", label.dataset.customTransportLabel === mode); + }); + } + async function loadCustomTransport(options = {}) { + const result = await runCommand("get mqtt.custom.transport", options); + if (!result.ok) return; + refreshCustomTransportUi(parseReplyValue(result.text)); + } + async function setCustomTransport(transport) { + const mode = transport === "wss" ? "wss" : "tcp"; + const result = await runCommand("set mqtt.custom.transport " + mode); + if (!result.ok) { + await loadCustomTransport({ recordHistory:false }); + return; + } + refreshCustomTransportUi(mode); + } function parseCustomEndpoint(value) { const endpoint = String(value || "").trim(); const separator = endpoint.lastIndexOf(":"); @@ -3052,6 +3086,16 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( } document.getElementById("refreshCustomEndpointBtn").onclick = () => loadCustomEndpoint(); document.getElementById("saveCustomEndpointBtn").onclick = () => saveCustomEndpoint(); + const customTransportSlider = document.getElementById("mqttCustomTransport"); + if (customTransportSlider) { + customTransportSlider.addEventListener("input", () => { + customTransportSlider.value = (Number.parseInt(customTransportSlider.value, 10) || 0) >= 1 ? "1" : "0"; + refreshCustomTransportUi(customTransportSlider.value === "1" ? "wss" : "tcp"); + }); + customTransportSlider.addEventListener("change", () => { + setCustomTransport((Number.parseInt(customTransportSlider.value, 10) || 0) >= 1 ? "wss" : "tcp"); + }); + } async function setLetsmeshMode(mode) { const eastmesh = document.getElementById("mqttEastmeshAu"); if (mode === "both" && eastmesh && eastmesh.checked) { @@ -3260,6 +3304,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( () => loadBrokerState("get mqtt.letsmesh-us", "mqttLetsmeshUs", quiet), () => loadBrokerState("get mqtt.custom", "mqttCustom", quiet), () => loadCustomEndpoint(quiet), + () => loadCustomTransport(quiet), () => loadField("get mqtt.custom.username", "mqttCustomUsername", null, quiet) ]); if (!isCurrentPageLoad(generation)) return;