From e74308f5e256dc98968ed0d9151b7fe6feed6477 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 14 Oct 2026 12:25:49 +0000 Subject: [PATCH] MQTT clients: shared open role, stored/revealable passwords, reset + open-access New clients join a shared 'open' role (pub/sub on #, $CONTROL stays protected) instead of per-client topic-scoped roles. All passwords are now stored encrypted with admin reveal + reset endpoints so one login can be reused across device batches. Username is the name slug (no random suffix). Legacy scoped clients can be moved onto the open role. Co-Authored-By: Claude Sonnet 5.5 --- src/db.js | 16 +++---- src/lib/mqtt-dynsec.js | 63 +++++++++++++++++--------- src/routes/mqtt-clients.js | 92 +++++++++++++++++++++++++++----------- 3 files changed, 115 insertions(+), 56 deletions(-) diff --git a/src/db.js b/src/db.js index e0e8d09..607d860 100644 --- a/src/db.js +++ b/src/db.js @@ -21,14 +21,14 @@ export async function initDb() { updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); - -- Broker logins scoped to a topic filter. The broker's own - -- dynamic-security.json is the source of truth for auth. For DEVICE clients - -- the password is show-once (never stored) — a human copies it into hardware. - -- For SERVICE clients (is_service=true, a stack app like hvac-backend that - -- must re-read its own login on every restart) the password IS stored, - -- AES-256-GCM encrypted (crypto.js, same as integration secrets), and served - -- to that app at runtime via GET /internal/mqtt-client/:username. This is the - -- only way a machine consumer can work — show-once can't be re-fetched. + -- Broker logins. The broker's own dynamic-security.json is the source of + -- truth for auth. New clients join the shared 'open' role (all topics) and + -- every password is stored AES-256-GCM encrypted (crypto.js, same as + -- integration secrets) so an admin can reveal it and reuse it across a batch + -- of devices. Legacy clients created before this have secret_enc NULL until + -- reset, and may still carry a per-client topic-scoped role. SERVICE clients + -- (is_service=true, e.g. hvac-backend) are additionally served to that app at + -- runtime via GET /internal/mqtt-client/:username. CREATE TABLE IF NOT EXISTS mqtt_clients ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, diff --git a/src/lib/mqtt-dynsec.js b/src/lib/mqtt-dynsec.js index 4d60649..cc2ce36 100644 --- a/src/lib/mqtt-dynsec.js +++ b/src/lib/mqtt-dynsec.js @@ -37,28 +37,46 @@ export function randomPassword() { return crypto.randomBytes(24).toString('base64').replace(/[^a-zA-Z0-9]/g, '').slice(0, 18) } -// `topics` is an array — a client can legitimately need several unrelated topic -// trees on one connection (e.g. hvac-backend needs both hvac/mhi/# and -// shellies/#). Each pattern gets its own ACL grant on the same role. -export async function createDynsecClient({ username, password, rolename, topics, canPublish, canSubscribe }) { - await dynsec('createClient', username, '-p', password) - await dynsec('createRole', rolename) - for (const topic of topics) { - if (canPublish) { - await dynsec('addRoleACL', rolename, 'publishClientSend', topic, 'allow') - await dynsec('addRoleACL', rolename, 'publishClientReceive', topic, 'allow') - } - if (canSubscribe) { - await dynsec('addRoleACL', rolename, 'subscribePattern', topic, 'allow') - await dynsec('addRoleACL', rolename, 'publishClientReceive', topic, 'allow') - } - } - await dynsec('addClientRole', username, rolename) +// Shared role every new client joins. Pub/sub on '#' (which never matches +// $-prefixed topics, so the $CONTROL dynsec admin channel stays protected) — +// the broker login itself is the only gate, so one credential can be reused +// across a whole batch of similar devices without per-client topic upkeep. +export const OPEN_ROLE = 'open' + +async function ignoreExists(promise) { + try { await promise } catch (e) { if (!/already exists/i.test(e.message)) throw e } } -// Add more topic grants to an already-existing client's role — used when a -// service client's needs grow later (e.g. hvac-backend gaining a new device -// family) without having to revoke/recreate the whole client. +export async function ensureOpenRole() { + await ignoreExists(dynsec('createRole', OPEN_ROLE)) + for (const aclType of ['publishClientSend', 'publishClientReceive', 'subscribePattern']) { + await ignoreExists(dynsec('addRoleACL', OPEN_ROLE, aclType, '#', 'allow')) + } +} + +export async function createDynsecClient({ username, password }) { + await ensureOpenRole() + await dynsec('createClient', username, '-p', password) + await dynsec('addClientRole', username, OPEN_ROLE) +} + +export async function setClientPassword(username, password) { + await dynsec('setClientPassword', username, password) +} + +// Move a legacy topic-scoped client onto the shared open role. Its old +// per-client role is removed best-effort (an orphaned role is inert). +export async function grantOpenAccess(username, oldRolename) { + await ensureOpenRole() + await dynsec('addClientRole', username, OPEN_ROLE) + if (oldRolename && oldRolename !== OPEN_ROLE) { + await dynsec('removeClientRole', username, oldRolename).catch(() => {}) + await dynsec('deleteRole', oldRolename).catch(() => {}) + } +} + +// Add more topic grants to a legacy topic-scoped client's role. Not used for +// clients on the shared open role (they already have everything). export async function addTopicsToRole(rolename, topics, { canPublish, canSubscribe }) { for (const topic of topics) { if (canPublish) { @@ -74,6 +92,7 @@ export async function addTopicsToRole(rolename, topics, { canPublish, canSubscri export async function deleteDynsecClient(username, rolename) { await dynsec('deleteClient', username) - // Role deletion is best-effort — a role left behind is inert, not a leak. - await dynsec('deleteRole', rolename).catch(() => {}) + // The shared open role must outlive any one client. Per-client legacy roles + // are deleted best-effort — a role left behind is inert, not a leak. + if (rolename !== OPEN_ROLE) await dynsec('deleteRole', rolename).catch(() => {}) } diff --git a/src/routes/mqtt-clients.js b/src/routes/mqtt-clients.js index afebc15..a91966f 100644 --- a/src/routes/mqtt-clients.js +++ b/src/routes/mqtt-clients.js @@ -1,8 +1,10 @@ -import crypto from 'crypto' import { pool } from '../db.js' import { requireAdmin } from '../auth.js' import { encrypt, decrypt } from '../crypto.js' -import { createDynsecClient, addTopicsToRole, deleteDynsecClient, slugify, randomPassword } from '../lib/mqtt-dynsec.js' +import { + createDynsecClient, addTopicsToRole, deleteDynsecClient, setClientPassword, grantOpenAccess, + slugify, randomPassword, OPEN_ROLE, +} from '../lib/mqtt-dynsec.js' const TOPIC_CHAR_RE = /^[a-zA-Z0-9/_+#-]+$/ const MQTT_BROKER_HOST = process.env.MQTT_BROKER_HOST || '10.10.10.104' @@ -59,26 +61,19 @@ export async function mqttClientRoutes(app) { } }) - // POST /settings/api/mqtt-clients — creates the broker identity, returns the - // password once. It is never persisted anywhere after this response. + // POST /settings/api/mqtt-clients — creates the broker identity on the shared + // open role (full pub/sub, no per-client topic scope). The password is stored + // encrypted so an admin can reveal it later and reuse it on a batch of devices. app.post('/mqtt-clients', { preHandler: requireAdmin }, async (req, reply) => { - const { name, topic_scope, can_publish, can_subscribe, is_service } = req.body || {} + const { name, is_service } = req.body || {} if (!name || typeof name !== 'string' || !name.trim()) { return reply.status(400).send({ error: 'Name is required' }) } - const { topics, error: topicError } = parseTopics(topic_scope) - if (topicError) return reply.status(400).send({ error: topicError }) - if (!can_publish && !can_subscribe) { - return reply.status(400).send({ error: 'At least one of publish or subscribe must be enabled' }) - } - // Service clients get a STABLE username = the name's slug (no random suffix), - // so the consuming app can look its credential up by a known id (e.g. an app - // named "hvac-backend" is reachable at /internal/mqtt-client/hvac-backend). - // Device clients keep the random suffix to avoid collisions. - const svc = !!is_service - const username = svc ? slugify(name) : `${slugify(name)}-${crypto.randomBytes(2).toString('hex')}` - const rolename = `${username}-role` + // Username is the name's slug — stable and memorable, so a service app can + // look its credential up by a known id (e.g. "hvac-backend") and a device + // batch ("tasmota-lights") shares one login. + const username = slugify(name) const password = randomPassword() const { rows: [dup] } = await pool.query('SELECT id FROM mqtt_clients WHERE username = $1', [username]) @@ -87,31 +82,76 @@ export async function mqttClientRoutes(app) { } try { - await createDynsecClient({ - username, password, rolename, topics, - canPublish: !!can_publish, canSubscribe: !!can_subscribe, - }) + await createDynsecClient({ username, password }) } catch (e) { // Best-effort cleanup of whatever partially got created before the failure. - deleteDynsecClient(username, rolename).catch(() => {}) + deleteDynsecClient(username, OPEN_ROLE).catch(() => {}) return reply.status(502).send({ error: `Broker error: ${e.message}` }) } const { rows: [row] } = await pool.query( `INSERT INTO mqtt_clients (name, username, rolename, topic_scope, can_publish, can_subscribe, is_service, secret_enc) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING ${CLIENT_COLS}`, - [name.trim(), username, rolename, topics.join('\n'), !!can_publish, !!can_subscribe, svc, svc ? encrypt(password) : null] + VALUES ($1, $2, $3, '#', true, true, $4, $5) RETURNING ${CLIENT_COLS}`, + [name.trim(), username, OPEN_ROLE, !!is_service, encrypt(password)] ) return { ...row, password } }) - // PATCH /settings/api/mqtt-clients/:id — add more topic scopes to an existing - // client's role, e.g. when a service client's needs grow. Additive only — this + // GET /settings/api/mqtt-clients/:id/password — admin reveal. Legacy clients + // created before passwords were stored have none; they need a reset. + app.get('/mqtt-clients/:id/password', { preHandler: requireAdmin }, async (req, reply) => { + const { rows: [row] } = await pool.query('SELECT active, secret_enc FROM mqtt_clients WHERE id = $1', [req.params.id]) + if (!row) return reply.status(404).send({ error: 'Not found' }) + if (!row.active) return reply.status(400).send({ error: 'Client is revoked' }) + if (!row.secret_enc) return reply.status(404).send({ error: 'No stored password for this client — reset it to set a new one' }) + return { password: decrypt(row.secret_enc) } + }) + + // POST /settings/api/mqtt-clients/:id/password — generate a new password, + // apply it on the broker and store it. Anything using the old one must be + // updated with the new value. + app.post('/mqtt-clients/:id/password', { preHandler: requireAdmin }, async (req, reply) => { + const { rows: [row] } = await pool.query('SELECT username, active FROM mqtt_clients WHERE id = $1', [req.params.id]) + if (!row) return reply.status(404).send({ error: 'Not found' }) + if (!row.active) return reply.status(400).send({ error: 'Client is revoked' }) + const password = randomPassword() + try { + await setClientPassword(row.username, password) + } catch (e) { + return reply.status(502).send({ error: `Broker error: ${e.message}` }) + } + await pool.query('UPDATE mqtt_clients SET secret_enc = $1 WHERE id = $2', [encrypt(password), req.params.id]) + return { password } + }) + + // POST /settings/api/mqtt-clients/:id/open-access — move a legacy + // topic-scoped client onto the shared open role. + app.post('/mqtt-clients/:id/open-access', { preHandler: requireAdmin }, async (req, reply) => { + const { rows: [row] } = await pool.query(`SELECT ${CLIENT_COLS} FROM mqtt_clients WHERE id = $1`, [req.params.id]) + if (!row) return reply.status(404).send({ error: 'Not found' }) + if (!row.active) return reply.status(400).send({ error: 'Client is revoked' }) + if (row.rolename === OPEN_ROLE) return row + try { + await grantOpenAccess(row.username, row.rolename) + } catch (e) { + return reply.status(502).send({ error: `Broker error: ${e.message}` }) + } + const { rows: [updated] } = await pool.query( + `UPDATE mqtt_clients SET rolename = $1, topic_scope = '#', can_publish = true, can_subscribe = true + WHERE id = $2 RETURNING ${CLIENT_COLS}`, + [OPEN_ROLE, req.params.id] + ) + return updated + }) + + // PATCH /settings/api/mqtt-clients/:id — add more topic scopes to a legacy + // topic-scoped client's role, e.g. when a service client's needs grow. Additive only — this // never removes an existing grant (revoke/delete the whole client for that). app.patch('/mqtt-clients/:id', { preHandler: requireAdmin }, async (req, reply) => { const { rows: [row] } = await pool.query(`SELECT ${CLIENT_COLS} FROM mqtt_clients WHERE id = $1`, [req.params.id]) if (!row) return reply.status(404).send({ error: 'Not found' }) if (!row.active) return reply.status(400).send({ error: 'Client is revoked' }) + if (row.rolename === OPEN_ROLE) return reply.status(400).send({ error: 'Client already has access to all topics' }) const { topics: newTopics, error: topicError } = parseTopics(req.body?.add_topic_scope) if (topicError) return reply.status(400).send({ error: topicError })