diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 5bed6837..3a2641ea 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -415,6 +415,12 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf } else if (p && p->topic_style == MQTT_TOPIC_MESHCORE && (strlen(_mqtt_prefs.mqtt_iata) == 0 || strcmp(_mqtt_prefs.mqtt_iata, "XXX") == 0)) { sprintf(reply, "OK - slot %d preset: %s (run 'set mqtt.iata ' to publish)", slot + 1, preset_name); + } else if (p && mqttPresetNeedsSlotPassword(p) && + _mqtt_prefs.mqtt_slot_password[slot][0] == '\0' && + !mqttPresetNeedsSlotUsername(p)) { + sprintf(reply, + "OK - slot %d preset: %s (run 'set mqtt%d.password ' to connect)", + slot + 1, preset_name, slot + 1); } else if (p && mqttPresetNeedsSlotCredentials(p) && (_mqtt_prefs.mqtt_slot_username[slot][0] == '\0' || _mqtt_prefs.mqtt_slot_password[slot][0] == '\0')) { diff --git a/src/helpers/MQTTPresets.h b/src/helpers/MQTTPresets.h index be43773a..3454fe91 100644 --- a/src/helpers/MQTTPresets.h +++ b/src/helpers/MQTTPresets.h @@ -43,10 +43,32 @@ struct MQTTPresetDef { const char* userpass_password; // MQTT_AUTH_USERPASS: embedded password, or nullptr to use mqttN.password }; -// True when preset uses MQTT_AUTH_USERPASS but credentials come from slot prefs (mqttN.username/password). -static inline bool mqttPresetNeedsSlotCredentials(const MQTTPresetDef* preset) { +// Sentinel: resolve MQTT username from device public-key hex at connect time. +// Braces match topic placeholders ({device}/{iata}); never send this string to the broker. +static const char MQTT_USERPASS_USERNAME_PUBKEY[] = "{pubkey}"; + +static inline bool mqttPresetUsesDevicePubkeyUsername(const MQTTPresetDef* preset) { return preset && preset->auth_type == MQTT_AUTH_USERPASS && - (!preset->userpass_username || !preset->userpass_password); + preset->userpass_username && + strcmp(preset->userpass_username, MQTT_USERPASS_USERNAME_PUBKEY) == 0; +} + +// True when USERPASS username must come from mqttN.username (null embedded username). +// "{pubkey}" is an embedded sentinel, so it does not need a slot username. +static inline bool mqttPresetNeedsSlotUsername(const MQTTPresetDef* preset) { + return preset && preset->auth_type == MQTT_AUTH_USERPASS && + !preset->userpass_username; +} + +// True when USERPASS password must come from mqttN.password (null embedded password). +static inline bool mqttPresetNeedsSlotPassword(const MQTTPresetDef* preset) { + return preset && preset->auth_type == MQTT_AUTH_USERPASS && + !preset->userpass_password; +} + +// True when preset uses MQTT_AUTH_USERPASS but at least one credential comes from slot prefs. +static inline bool mqttPresetNeedsSlotCredentials(const MQTTPresetDef* preset) { + return mqttPresetNeedsSlotUsername(preset) || mqttPresetNeedsSlotPassword(preset); } // Google Trust Services - GTS Root R4 (used by LetsMesh Analyzer) @@ -108,7 +130,7 @@ static const char ISRG_ROOT_X1[] PROGMEM = "-----END CERTIFICATE-----\n"; // Number of built-in presets -static const int MQTT_PRESET_COUNT = 26; +static const int MQTT_PRESET_COUNT = 28; // Built-in preset definitions (stored in flash) static const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = { @@ -144,6 +166,10 @@ static const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = { { "ipnt.uk", "wss://mqtt.ipnt.uk:443", "mqtt.ipnt.uk", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "flmesh", "wss://mcmqtt.jntconnections.com:443", "mcmqtt.jntconnections.com", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, { "corecomms", "wss://mqtt.corecomms.net:443/mqtt", "mqtt.corecomms.net", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, + // Username is device pubkey hex at connect; password from mqttN.password. No TLS. + { "mesh-chaun14", "mqtt://mqtt.mesh.chaun14.fr:1884", nullptr, nullptr, MQTT_AUTH_USERPASS, MQTT_TOPIC_MESHCORE, 0, true, 60, MQTT_USERPASS_USERNAME_PUBKEY, nullptr }, + // LetsMesh-compatible JWT; TLS is Let's Encrypt (ISRG Root X1), not GTS. + { "wcmesh", "wss://mqtt.wcmesh.com:443", "mqtt.wcmesh.com", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, }; // Find a preset by name, returns nullptr if not found diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 25c67d9b..7c6b9776 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -1648,10 +1648,19 @@ void MQTTBridge::setupSlot(int index) { slot.client->setCredentials(_jwt_username, slot.auth_token); } } else if (slot.preset->auth_type == MQTT_AUTH_USERPASS) { - if (slot.preset->userpass_username && slot.preset->userpass_password) { - slot.client->setCredentials(slot.preset->userpass_username, slot.preset->userpass_password); - } else if (strlen(slot.username) > 0) { - slot.client->setCredentials(slot.username, slot.password); + const char* user = nullptr; + const char* pass = slot.preset->userpass_password + ? slot.preset->userpass_password + : slot.password; + if (mqttPresetUsesDevicePubkeyUsername(slot.preset)) { + user = _device_id; // never send "{pubkey}" literally + } else if (slot.preset->userpass_username) { + user = slot.preset->userpass_username; + } else if (slot.username[0] != '\0') { + user = slot.username; + } + if (user && user[0] != '\0' && pass && pass[0] != '\0') { + slot.client->setCredentials(user, pass); } } } else { @@ -2507,15 +2516,15 @@ bool MQTTBridge::isSlotReady(int index, char* reason_buf, size_t reason_size) co return false; } } - if (mqttPresetNeedsSlotCredentials(slot.preset)) { - if (_obs->mqtt_slot_username[index][0] == '\0') { - if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt%d.username ", index + 1); - return false; - } - if (_obs->mqtt_slot_password[index][0] == '\0') { - if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt%d.password ", index + 1); - return false; - } + if (mqttPresetNeedsSlotUsername(slot.preset) && + _obs->mqtt_slot_username[index][0] == '\0') { + if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt%d.username ", index + 1); + return false; + } + if (mqttPresetNeedsSlotPassword(slot.preset) && + _obs->mqtt_slot_password[index][0] == '\0') { + if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt%d.password ", index + 1); + return false; } } else { // Custom slot without a topic template uses meshcore format, needs IATA diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 8b4edb6a..8093c969 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -878,7 +878,11 @@ void WebConfigServer::handlePresets(AsyncWebServerRequest* req) { // What the UI must collect for this preset to connect if (p.topic_style == MQTT_TOPIC_MESHRANK) { o["needs"] = "token"; - } else if (mqttPresetNeedsSlotCredentials(&p)) { + } else if (mqttPresetNeedsSlotUsername(&p) && mqttPresetNeedsSlotPassword(&p)) { + o["needs"] = "userpass"; + } else if (mqttPresetNeedsSlotPassword(&p)) { + o["needs"] = "password"; + } else if (mqttPresetNeedsSlotUsername(&p)) { o["needs"] = "userpass"; } else { o["needs"] = "none"; diff --git a/test/test_mqtt_presets/test_mqtt_presets.cpp b/test/test_mqtt_presets/test_mqtt_presets.cpp index 78b5228d..64eb79d5 100644 --- a/test/test_mqtt_presets/test_mqtt_presets.cpp +++ b/test/test_mqtt_presets/test_mqtt_presets.cpp @@ -105,6 +105,9 @@ TEST(MQTTPresets, UserpassWithoutEmbeddedCredsNeedsSlotCredentials) { ASSERT_NE(nullptr, p); EXPECT_EQ(MQTT_AUTH_USERPASS, p->auth_type); EXPECT_TRUE(mqttPresetNeedsSlotCredentials(p)); + EXPECT_TRUE(mqttPresetNeedsSlotUsername(p)); + EXPECT_TRUE(mqttPresetNeedsSlotPassword(p)); + EXPECT_FALSE(mqttPresetUsesDevicePubkeyUsername(p)); } TEST(MQTTPresets, NonUserpassNeverNeedsSlotCredentials) { @@ -124,6 +127,34 @@ TEST(MQTTPresets, MeshrankIsTokenStyleNoAuth) { EXPECT_EQ(MQTT_AUTH_NONE, p->auth_type); } +TEST(MQTTPresets, MeshChaun14UsesPubkeyUsernameAndNeedsPassword) { + const MQTTPresetDef* p = findMQTTPreset("mesh-chaun14"); + ASSERT_NE(nullptr, p); + EXPECT_EQ(MQTT_AUTH_USERPASS, p->auth_type); + EXPECT_EQ(MQTT_TOPIC_MESHCORE, p->topic_style); + EXPECT_STREQ("mqtt://mqtt.mesh.chaun14.fr:1884", p->server_url); + EXPECT_EQ(nullptr, p->ca_cert); + EXPECT_EQ(60, p->keepalive); + EXPECT_TRUE(mqttPresetUsesDevicePubkeyUsername(p)); + EXPECT_STREQ(MQTT_USERPASS_USERNAME_PUBKEY, p->userpass_username); + EXPECT_FALSE(mqttPresetNeedsSlotUsername(p)); + EXPECT_TRUE(mqttPresetNeedsSlotPassword(p)); + EXPECT_TRUE(mqttPresetNeedsSlotCredentials(p)); +} + +TEST(MQTTPresets, WcmeshIsJwtWithIsrgRootX1) { + const MQTTPresetDef* p = findMQTTPreset("wcmesh"); + ASSERT_NE(nullptr, p); + EXPECT_EQ(MQTT_AUTH_JWT, p->auth_type); + EXPECT_EQ(MQTT_TOPIC_MESHCORE, p->topic_style); + EXPECT_STREQ("wss://mqtt.wcmesh.com:443", p->server_url); + EXPECT_STREQ("mqtt.wcmesh.com", p->jwt_audience); + EXPECT_EQ(ISRG_ROOT_X1, p->ca_cert); + EXPECT_NE(GTS_ROOT_R4, p->ca_cert); + EXPECT_FALSE(mqttPresetNeedsSlotCredentials(p)); + EXPECT_FALSE(mqttPresetUsesDevicePubkeyUsername(p)); +} + // ---- slot count constants ------------------------------------------------- TEST(MQTTPresets, SlotCountsAreSane) { diff --git a/webui/index.html b/webui/index.html index b7b65474..bc09bee0 100644 --- a/webui/index.html +++ b/webui/index.html @@ -639,7 +639,7 @@ function refreshSlotFields(host,i){ var def=null;st.presets.forEach(function(p){if(p.name===name)def=p}); var needs=name==="custom"?"custom":(def?def.needs:"none"); var showMap={token:needs==="token",username:needs==="userpass"||needs==="custom", - password:needs==="userpass"||needs==="custom",server:needs==="custom", + password:needs==="userpass"||needs==="password"||needs==="custom",server:needs==="custom", port:needs==="custom",topic:needs==="custom",audience:needs==="custom"}; Object.keys(showMap).forEach(function(f){ var w=card.querySelector(".sf-"+f);if(w)w.classList.toggle("hide",!showMap[f]);