feat(mqtt): add mesh-chaun14 and wcmesh presets

Support "{pubkey}" username sentinel so brokers can auth with the
device public key without enlarging the prefs username field.
This commit is contained in:
agessaman
2026-07-19 23:45:28 -07:00
parent 329d7f86c2
commit ebca9bdd3e
6 changed files with 95 additions and 19 deletions
+6
View File
@@ -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 <airport_code>' 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 <pass>' 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')) {
+30 -4
View File
@@ -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
+22 -13
View File
@@ -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 <user>", index + 1);
return false;
}
if (_obs->mqtt_slot_password[index][0] == '\0') {
if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt%d.password <pass>", 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 <user>", 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 <pass>", index + 1);
return false;
}
} else {
// Custom slot without a topic template uses meshcore format, needs IATA
+5 -1
View File
@@ -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";
@@ -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) {
+1 -1
View File
@@ -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]);