Skip to content

Commit ba39012

Browse files
author
Ravi Singh
committed
feat: MQTTS support + auto MQTT credential provisioning
Firmware v2.2.0: - Add use_tls field to MQTT config (TLS via ESP-IDF cert bundle) - Auto-select port 8883 when TLS enabled - Web API /api/mqtt accepts use_tls parameter - Both ESP32 DevKit and C3 receivers updated Cloud: - MQTT credential auto-generation during QR device linking - Per-user ACL: each user can only pub/sub their own device topics - Auto-push MQTT config to receiver during claim flow - Credential revocation on site deletion - Mosquitto password file + ACL managed programmatically
1 parent b904f33 commit ba39012

8 files changed

Lines changed: 189 additions & 29 deletions

File tree

firmware/receiver-c3/components/mqtt_client/mqtt_manager.c

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
// SPDX-License-Identifier: MIT
2-
// Copyright (c) 2025-2026 Ravi Singh (Techposts)
3-
41
/**
52
* mqtt_manager implementation
63
*/
74

85
#include "mqtt_manager.h"
96
#include "mqtt_client.h" // ESP-IDF MQTT (component: mqtt)
7+
#include "esp_tls.h"
8+
#include "esp_crt_bundle.h" // ESP-IDF certificate bundle for TLS
109
#include "transmitter_registry.h"
1110
#include "wifi_manager.h"
1211
#include "esp_log.h"
@@ -56,11 +55,13 @@ static void load_config(void) {
5655
nvs_get_str(h, "user", s_cfg.user, &len);
5756
len = sizeof(s_cfg.pass);
5857
nvs_get_str(h, "pass", s_cfg.pass, &len);
59-
uint8_t en = 0, ha = 0;
58+
uint8_t en = 0, ha = 0, tls = 0;
6059
nvs_get_u8(h, "enabled", &en);
6160
nvs_get_u8(h, "ha_disc", &ha);
61+
nvs_get_u8(h, "use_tls", &tls);
6262
s_cfg.enabled = (en != 0);
6363
s_cfg.ha_discovery = (ha != 0);
64+
s_cfg.use_tls = (tls != 0);
6465
nvs_close(h);
6566
}
6667

@@ -74,6 +75,7 @@ static esp_err_t save_config_nvs(const mqtt_mgr_config_t *cfg) {
7475
if (strlen(cfg->pass) > 0) nvs_set_str(h, "pass", cfg->pass); // only update if provided
7576
nvs_set_u8 (h, "enabled", cfg->enabled ? 1 : 0);
7677
nvs_set_u8 (h, "ha_disc", cfg->ha_discovery ? 1 : 0);
78+
nvs_set_u8 (h, "use_tls", cfg->use_tls ? 1 : 0);
7779
err = nvs_commit(h);
7880
nvs_close(h);
7981
return err;
@@ -149,10 +151,13 @@ void mqtt_manager_start(void) {
149151
char client_id[32];
150152
snprintf(client_id, sizeof(client_id), "tanksync_%s", s_dev_id);
151153

154+
uint16_t port = s_cfg.port ? s_cfg.port : (s_cfg.use_tls ? 8883 : MQTT_DEFAULT_PORT);
155+
152156
esp_mqtt_client_config_t cfg = {
153157
.broker.address.hostname = s_cfg.host,
154-
.broker.address.port = s_cfg.port ? s_cfg.port : MQTT_DEFAULT_PORT,
155-
.broker.address.transport = MQTT_TRANSPORT_OVER_TCP,
158+
.broker.address.port = port,
159+
.broker.address.transport = s_cfg.use_tls ? MQTT_TRANSPORT_OVER_SSL : MQTT_TRANSPORT_OVER_TCP,
160+
.broker.verification.crt_bundle_attach = s_cfg.use_tls ? esp_crt_bundle_attach : NULL,
156161
.credentials.client_id = client_id,
157162
.credentials.username = strlen(s_cfg.user) ? s_cfg.user : NULL,
158163
.credentials.authentication.password = strlen(s_cfg.pass) ? s_cfg.pass : NULL,
@@ -363,6 +368,7 @@ esp_err_t mqtt_manager_set_config(const mqtt_mgr_config_t *cfg) {
363368
strncpy(s_cfg.user, cfg->user, sizeof(s_cfg.user) - 1);
364369
s_cfg.enabled = cfg->enabled;
365370
s_cfg.ha_discovery = cfg->ha_discovery;
371+
s_cfg.use_tls = cfg->use_tls;
366372

367373
mqtt_manager_stop();
368374
if (s_cfg.enabled && strlen(s_cfg.host) > 0 &&

firmware/receiver-c3/components/mqtt_client/mqtt_manager.h

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
// SPDX-License-Identifier: MIT
2-
// Copyright (c) 2025-2026 Ravi Singh (Techposts)
3-
41
/**
52
* mqtt_manager - MQTT publishing for TankSync receiver
63
*
@@ -41,7 +38,7 @@
4138
#define MQTT_RECONNECT_BASE_MS 5000
4239
#endif
4340
#ifndef FIRMWARE_VERSION
44-
#define FIRMWARE_VERSION "2.1.0"
41+
#define FIRMWARE_VERSION "2.2.0"
4542
#endif
4643

4744
typedef enum {
@@ -59,6 +56,7 @@ typedef struct {
5956
char pass[64];
6057
bool enabled;
6158
bool ha_discovery;
59+
bool use_tls; // true = MQTTS (port 8883), false = plain TCP (1883)
6260
} mqtt_mgr_config_t;
6361

6462
/** Initialize: load config from NVS, derive device_id from MAC. */

firmware/receiver-c3/components/web_server/web_server.c

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
// SPDX-License-Identifier: MIT
2-
// Copyright (c) 2025-2026 Ravi Singh (Techposts)
3-
41
/**
52
* web_server implementation - TankSync Terminal UI v3.5
63
*/
@@ -652,6 +649,7 @@ static esp_err_t handle_get_mqtt(httpd_req_t *req) {
652649
(ms == MQTT_ST_ERROR) ? "error" : "disabled";
653650
cJSON *root = cJSON_CreateObject(); cJSON_AddStringToObject(root, "host", cfg.host); cJSON_AddNumberToObject(root, "port", cfg.port);
654651
cJSON_AddStringToObject(root, "user", cfg.user); cJSON_AddBoolToObject(root, "enabled", cfg.enabled); cJSON_AddBoolToObject(root, "ha_discovery", cfg.ha_discovery);
652+
cJSON_AddBoolToObject(root, "use_tls", cfg.use_tls);
655653
cJSON_AddStringToObject(root, "live_status", mqtt_live);
656654
char *json = cJSON_PrintUnformatted(root); cJSON_Delete(root); send_json(req, json); free(json); return ESP_OK;
657655
}
@@ -664,6 +662,7 @@ static esp_err_t handle_post_mqtt(httpd_req_t *req) {
664662
if (u) { strncpy(cfg.user, u, sizeof(cfg.user)-1); }
665663
if (p) { strncpy(cfg.pass, p, sizeof(cfg.pass)-1); }
666664
cfg.port = (uint16_t)cJSON_GetNumberValue(cJSON_GetObjectItem(j, "port")); cfg.enabled = cJSON_IsTrue(cJSON_GetObjectItem(j, "enabled")); cfg.ha_discovery = cJSON_IsTrue(cJSON_GetObjectItem(j, "ha_discovery"));
665+
cfg.use_tls = cJSON_IsTrue(cJSON_GetObjectItem(j, "use_tls"));
667666
cJSON_Delete(j); if (mqtt_manager_set_config(&cfg) == ESP_OK) { send_ok(req, "OK"); } else { send_err(req, "NO"); }
668667
return ESP_OK;
669668
}

firmware/receiver/components/mqtt_client/mqtt_manager.c

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
// SPDX-License-Identifier: MIT
2-
// Copyright (c) 2025-2026 Ravi Singh (Techposts)
3-
41
/**
52
* mqtt_manager implementation
63
*/
74

85
#include "mqtt_manager.h"
96
#include "mqtt_client.h" // ESP-IDF MQTT (component: mqtt)
7+
#include "esp_tls.h"
8+
#include "esp_crt_bundle.h" // ESP-IDF certificate bundle for TLS
109
#include "transmitter_registry.h"
1110
#include "wifi_manager.h"
1211
#include "esp_log.h"
@@ -56,11 +55,13 @@ static void load_config(void) {
5655
nvs_get_str(h, "user", s_cfg.user, &len);
5756
len = sizeof(s_cfg.pass);
5857
nvs_get_str(h, "pass", s_cfg.pass, &len);
59-
uint8_t en = 0, ha = 0;
58+
uint8_t en = 0, ha = 0, tls = 0;
6059
nvs_get_u8(h, "enabled", &en);
6160
nvs_get_u8(h, "ha_disc", &ha);
61+
nvs_get_u8(h, "use_tls", &tls);
6262
s_cfg.enabled = (en != 0);
6363
s_cfg.ha_discovery = (ha != 0);
64+
s_cfg.use_tls = (tls != 0);
6465
nvs_close(h);
6566
}
6667

@@ -74,6 +75,7 @@ static esp_err_t save_config_nvs(const mqtt_mgr_config_t *cfg) {
7475
if (strlen(cfg->pass) > 0) nvs_set_str(h, "pass", cfg->pass); // only update if provided
7576
nvs_set_u8 (h, "enabled", cfg->enabled ? 1 : 0);
7677
nvs_set_u8 (h, "ha_disc", cfg->ha_discovery ? 1 : 0);
78+
nvs_set_u8 (h, "use_tls", cfg->use_tls ? 1 : 0);
7779
err = nvs_commit(h);
7880
nvs_close(h);
7981
return err;
@@ -149,10 +151,13 @@ void mqtt_manager_start(void) {
149151
char client_id[32];
150152
snprintf(client_id, sizeof(client_id), "tanksync_%s", s_dev_id);
151153

154+
uint16_t port = s_cfg.port ? s_cfg.port : (s_cfg.use_tls ? 8883 : MQTT_DEFAULT_PORT);
155+
152156
esp_mqtt_client_config_t cfg = {
153157
.broker.address.hostname = s_cfg.host,
154-
.broker.address.port = s_cfg.port ? s_cfg.port : MQTT_DEFAULT_PORT,
155-
.broker.address.transport = MQTT_TRANSPORT_OVER_TCP,
158+
.broker.address.port = port,
159+
.broker.address.transport = s_cfg.use_tls ? MQTT_TRANSPORT_OVER_SSL : MQTT_TRANSPORT_OVER_TCP,
160+
.broker.verification.crt_bundle_attach = s_cfg.use_tls ? esp_crt_bundle_attach : NULL,
156161
.credentials.client_id = client_id,
157162
.credentials.username = strlen(s_cfg.user) ? s_cfg.user : NULL,
158163
.credentials.authentication.password = strlen(s_cfg.pass) ? s_cfg.pass : NULL,
@@ -363,6 +368,7 @@ esp_err_t mqtt_manager_set_config(const mqtt_mgr_config_t *cfg) {
363368
strncpy(s_cfg.user, cfg->user, sizeof(s_cfg.user) - 1);
364369
s_cfg.enabled = cfg->enabled;
365370
s_cfg.ha_discovery = cfg->ha_discovery;
371+
s_cfg.use_tls = cfg->use_tls;
366372

367373
mqtt_manager_stop();
368374
if (s_cfg.enabled && strlen(s_cfg.host) > 0 &&

firmware/receiver/components/mqtt_client/mqtt_manager.h

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
// SPDX-License-Identifier: MIT
2-
// Copyright (c) 2025-2026 Ravi Singh (Techposts)
3-
41
/**
52
* mqtt_manager - MQTT publishing for TankSync receiver
63
*
@@ -41,7 +38,7 @@
4138
#define MQTT_RECONNECT_BASE_MS 5000
4239
#endif
4340
#ifndef FIRMWARE_VERSION
44-
#define FIRMWARE_VERSION "2.1.0"
41+
#define FIRMWARE_VERSION "2.2.0"
4542
#endif
4643

4744
typedef enum {
@@ -59,6 +56,7 @@ typedef struct {
5956
char pass[64];
6057
bool enabled;
6158
bool ha_discovery;
59+
bool use_tls; // true = MQTTS (port 8883), false = plain TCP (1883)
6260
} mqtt_mgr_config_t;
6361

6462
/** Initialize: load config from NVS, derive device_id from MAC. */

firmware/receiver/components/web_server/web_server.c

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
// SPDX-License-Identifier: MIT
2-
// Copyright (c) 2025-2026 Ravi Singh (Techposts)
3-
41
/**
52
* web_server implementation - TankSync Terminal UI v3.5
63
*/
@@ -652,6 +649,7 @@ static esp_err_t handle_get_mqtt(httpd_req_t *req) {
652649
(ms == MQTT_ST_ERROR) ? "error" : "disabled";
653650
cJSON *root = cJSON_CreateObject(); cJSON_AddStringToObject(root, "host", cfg.host); cJSON_AddNumberToObject(root, "port", cfg.port);
654651
cJSON_AddStringToObject(root, "user", cfg.user); cJSON_AddBoolToObject(root, "enabled", cfg.enabled); cJSON_AddBoolToObject(root, "ha_discovery", cfg.ha_discovery);
652+
cJSON_AddBoolToObject(root, "use_tls", cfg.use_tls);
655653
cJSON_AddStringToObject(root, "live_status", mqtt_live);
656654
char *json = cJSON_PrintUnformatted(root); cJSON_Delete(root); send_json(req, json); free(json); return ESP_OK;
657655
}
@@ -664,6 +662,7 @@ static esp_err_t handle_post_mqtt(httpd_req_t *req) {
664662
if (u) { strncpy(cfg.user, u, sizeof(cfg.user)-1); }
665663
if (p) { strncpy(cfg.pass, p, sizeof(cfg.pass)-1); }
666664
cfg.port = (uint16_t)cJSON_GetNumberValue(cJSON_GetObjectItem(j, "port")); cfg.enabled = cJSON_IsTrue(cJSON_GetObjectItem(j, "enabled")); cfg.ha_discovery = cJSON_IsTrue(cJSON_GetObjectItem(j, "ha_discovery"));
665+
cfg.use_tls = cJSON_IsTrue(cJSON_GetObjectItem(j, "use_tls"));
667666
cJSON_Delete(j); if (mqtt_manager_set_config(&cfg) == ESP_OK) { send_ok(req, "OK"); } else { send_err(req, "NO"); }
668667
return ESP_OK;
669668
}

pwa/server-cloud/index.js

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import db, { stmts } from './db.js';
1515
import { connectMqtt } from './mqtt.js';
1616
import { addClient } from './sse.js';
1717
import { checkDeviceTimeouts } from './alerts.js';
18+
import { generateMqttCredentials, pushMqttToReceiver, revokeMqttCredentials } from './mqtt-credentials.js';
1819

1920
const __dirname = dirname(fileURLToPath(import.meta.url));
2021
const isProd = process.env.NODE_ENV === 'production';
@@ -208,8 +209,10 @@ app.put('/api/sites/:id', { preHandler: [app.authenticate] }, async (req, reply)
208209
});
209210

210211
app.delete('/api/sites/:id', { preHandler: [app.authenticate] }, async (req, reply) => {
211-
const result = await db.run('DELETE FROM sites WHERE id = $1 AND user_id = $2', req.params.id, req.user.id);
212-
if (result.changes === 0) return reply.code(404).send({ error: 'Site not found' });
212+
const site = await db.get('SELECT * FROM sites WHERE id = $1 AND user_id = $2', req.params.id, req.user.id);
213+
if (!site) return reply.code(404).send({ error: 'Site not found' });
214+
await revokeMqttCredentials(site.id);
215+
await db.run('DELETE FROM sites WHERE id = $1', site.id);
213216
return { success: true };
214217
});
215218

@@ -324,7 +327,28 @@ app.post('/api/link/claim', { preHandler: [app.authenticate] }, async (req, repl
324327
}
325328
} catch {}
326329

327-
return { site_id: siteId, device_count: deviceCount, message: 'Device linked successfully' };
330+
// 6. Generate MQTT credentials and push to receiver
331+
let mqtt_configured = false;
332+
let mqttCreds = null;
333+
try {
334+
mqttCreds = await generateMqttCredentials(req.user.id, siteId, device_id);
335+
mqtt_configured = await pushMqttToReceiver(receiver_ip, mqttCreds);
336+
if (mqtt_configured) {
337+
app.log.info(`MQTT auto-configured on receiver ${receiver_ip} — user ${mqttCreds.mqtt_username}`);
338+
}
339+
} catch (err) {
340+
app.log.warn(`MQTT credential setup failed: ${err.message}`);
341+
}
342+
343+
return {
344+
site_id: siteId,
345+
device_count: deviceCount,
346+
mqtt_configured,
347+
mqtt_host: mqttCreds?.mqtt_host,
348+
message: mqtt_configured
349+
? 'Device linked and MQTT configured automatically'
350+
: 'Device linked. Configure MQTT manually in the receiver web UI.',
351+
};
328352
});
329353

330354
// ─── HISTORY ───────────────────────────────────────────────────────────────────
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// Copyright (c) 2025-2026 Ravi Singh (Techposts)
3+
4+
import { randomBytes } from 'crypto';
5+
import { execSync } from 'child_process';
6+
import { readFileSync, writeFileSync } from 'fs';
7+
import db from './db.js';
8+
9+
const PASSWD_FILE = '/etc/mosquitto/tanksync_passwd';
10+
const ACL_FILE = '/etc/mosquitto/tanksync_acl.conf';
11+
const MQTT_PUBLIC_HOST = process.env.MQTT_PUBLIC_HOST || 'mqtt.smartghar.org';
12+
const MQTT_PUBLIC_PORT = parseInt(process.env.MQTT_PUBLIC_PORT || '8883');
13+
14+
/**
15+
* Generate MQTT credentials for a user+site, add to Mosquitto, push to receiver.
16+
* Returns { mqtt_username, mqtt_password, mqtt_host, mqtt_port }
17+
*/
18+
export async function generateMqttCredentials(userId, siteId, deviceId) {
19+
// Check if credentials already exist for this site
20+
const existing = await db.get(
21+
'SELECT * FROM mqtt_credentials WHERE site_id = $1', siteId
22+
);
23+
if (existing) {
24+
return {
25+
mqtt_username: existing.mqtt_username,
26+
mqtt_password: existing.mqtt_password,
27+
mqtt_host: MQTT_PUBLIC_HOST,
28+
mqtt_port: MQTT_PUBLIC_PORT,
29+
};
30+
}
31+
32+
// Generate unique username and password
33+
const shortId = deviceId.slice(0, 6);
34+
const mqtt_username = `ts_u${userId}_${shortId}`;
35+
const mqtt_password = randomBytes(16).toString('base64url');
36+
37+
// Add to Mosquitto password file
38+
try {
39+
execSync(`sudo mosquitto_passwd -b ${PASSWD_FILE} "${mqtt_username}" "${mqtt_password}"`, { timeout: 5000 });
40+
} catch (err) {
41+
console.error(`[MQTT-CRED] Failed to add password: ${err.message}`);
42+
throw new Error('Failed to create MQTT credentials');
43+
}
44+
45+
// Store in database
46+
await db.run(
47+
'INSERT INTO mqtt_credentials (user_id, site_id, mqtt_username, mqtt_password, device_id) VALUES ($1, $2, $3, $4, $5)',
48+
userId, siteId, mqtt_username, mqtt_password, deviceId
49+
);
50+
51+
// Regenerate ACL file
52+
await regenerateAcl();
53+
54+
// Reload Mosquitto config
55+
try {
56+
execSync('sudo kill -HUP $(pidof mosquitto)', { timeout: 5000 });
57+
} catch {
58+
console.warn('[MQTT-CRED] Could not reload Mosquitto — may need manual restart');
59+
}
60+
61+
return { mqtt_username, mqtt_password, mqtt_host: MQTT_PUBLIC_HOST, mqtt_port: MQTT_PUBLIC_PORT };
62+
}
63+
64+
/**
65+
* Push MQTT config to receiver via its local HTTP API.
66+
*/
67+
export async function pushMqttToReceiver(receiverIp, mqttCreds) {
68+
try {
69+
const res = await fetch(`http://${receiverIp}/api/mqtt`, {
70+
method: 'POST',
71+
headers: { 'Content-Type': 'application/json' },
72+
body: JSON.stringify({
73+
host: mqttCreds.mqtt_host,
74+
port: mqttCreds.mqtt_port,
75+
user: mqttCreds.mqtt_username,
76+
pass: mqttCreds.mqtt_password,
77+
enabled: true,
78+
ha_discovery: false,
79+
use_tls: true,
80+
}),
81+
signal: AbortSignal.timeout(5000),
82+
});
83+
return res.ok;
84+
} catch (err) {
85+
console.warn(`[MQTT-CRED] Could not push to receiver ${receiverIp}: ${err.message}`);
86+
return false;
87+
}
88+
}
89+
90+
/**
91+
* Revoke MQTT credentials for a site (called when site is deleted).
92+
*/
93+
export async function revokeMqttCredentials(siteId) {
94+
const cred = await db.get('SELECT * FROM mqtt_credentials WHERE site_id = $1', siteId);
95+
if (!cred) return;
96+
97+
// Remove from Mosquitto password file
98+
try {
99+
execSync(`sudo mosquitto_passwd -D ${PASSWD_FILE} "${cred.mqtt_username}"`, { timeout: 5000 });
100+
} catch {}
101+
102+
// Remove from DB
103+
await db.run('DELETE FROM mqtt_credentials WHERE site_id = $1', siteId);
104+
105+
// Regenerate ACL and reload
106+
await regenerateAcl();
107+
try { execSync('sudo kill -HUP $(pidof mosquitto)', { timeout: 5000 }); } catch {}
108+
}
109+
110+
/**
111+
* Regenerate the ACL file from all credentials in the database.
112+
*/
113+
async function regenerateAcl() {
114+
const allCreds = await db.all('SELECT * FROM mqtt_credentials');
115+
116+
let acl = `# TankSync MQTT ACL — auto-generated, do not edit manually
117+
# Server account — full access
118+
user tanksync_server
119+
topic readwrite tanksync/#
120+
121+
`;
122+
123+
for (const cred of allCreds) {
124+
acl += `# User ${cred.user_id}, Site ${cred.site_id}\n`;
125+
acl += `user ${cred.mqtt_username}\n`;
126+
acl += `topic readwrite tanksync/${cred.device_id}/#\n\n`;
127+
}
128+
129+
writeFileSync(ACL_FILE, acl);
130+
}

0 commit comments

Comments
 (0)