diff --git a/src/lib/mqtt-dynsec.js b/src/lib/mqtt-dynsec.js index 498bb74..4d60649 100644 --- a/src/lib/mqtt-dynsec.js +++ b/src/lib/mqtt-dynsec.js @@ -37,20 +37,41 @@ export function randomPassword() { return crypto.randomBytes(24).toString('base64').replace(/[^a-zA-Z0-9]/g, '').slice(0, 18) } -export async function createDynsecClient({ username, password, rolename, topic, canPublish, canSubscribe }) { +// `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) - 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') + 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) } +// 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 addTopicsToRole(rolename, topics, { canPublish, canSubscribe }) { + 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') + } + } +} + export async function deleteDynsecClient(username, rolename) { await dynsec('deleteClient', username) // Role deletion is best-effort — a role left behind is inert, not a leak. diff --git a/src/routes/mqtt-clients.js b/src/routes/mqtt-clients.js index d96f6a2..afebc15 100644 --- a/src/routes/mqtt-clients.js +++ b/src/routes/mqtt-clients.js @@ -2,9 +2,9 @@ import crypto from 'crypto' import { pool } from '../db.js' import { requireAdmin } from '../auth.js' import { encrypt, decrypt } from '../crypto.js' -import { createDynsecClient, deleteDynsecClient, slugify, randomPassword } from '../lib/mqtt-dynsec.js' +import { createDynsecClient, addTopicsToRole, deleteDynsecClient, slugify, randomPassword } from '../lib/mqtt-dynsec.js' -const TOPIC_RE = /^[a-zA-Z0-9/_+#-]+$/ +const TOPIC_CHAR_RE = /^[a-zA-Z0-9/_+#-]+$/ const MQTT_BROKER_HOST = process.env.MQTT_BROKER_HOST || '10.10.10.104' const MQTT_BROKER_PORT = Number(process.env.MQTT_BROKER_PORT) || 1883 @@ -12,6 +12,23 @@ const MQTT_BROKER_PORT = Number(process.env.MQTT_BROKER_PORT) || 1883 // internal service-to-service endpoint below. const CLIENT_COLS = 'id, name, username, rolename, topic_scope, can_publish, can_subscribe, is_service, active, created_at, revoked_at' +// topic_scope is stored as newline-joined topic filters (a client can need +// several unrelated topic trees on one connection — e.g. hvac-backend needs +// both hvac/mhi/# and shellies/#). Parses/validates the raw textarea input. +function parseTopics(raw) { + const topics = String(raw || '') + .split(/[\n,]/) + .map(t => t.trim()) + .filter(Boolean) + if (!topics.length) return { error: 'At least one topic scope is required' } + for (const t of topics) { + if (!TOPIC_CHAR_RE.test(t)) { + return { error: `Invalid topic "${t}" — only letters, numbers, / _ + - # are allowed` } + } + } + return { topics } +} + export async function mqttClientRoutes(app) { // GET /settings/api/mqtt-clients app.get('/mqtt-clients', { preHandler: requireAdmin }, async () => { @@ -49,10 +66,8 @@ export async function mqttClientRoutes(app) { if (!name || typeof name !== 'string' || !name.trim()) { return reply.status(400).send({ error: 'Name is required' }) } - const topic = (topic_scope || '').trim() - if (!topic || !TOPIC_RE.test(topic)) { - return reply.status(400).send({ error: 'Topic scope must contain only letters, numbers, / _ + - #' }) - } + 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' }) } @@ -73,7 +88,7 @@ export async function mqttClientRoutes(app) { try { await createDynsecClient({ - username, password, rolename, topic, + username, password, rolename, topics, canPublish: !!can_publish, canSubscribe: !!can_subscribe, }) } catch (e) { @@ -85,11 +100,38 @@ export async function mqttClientRoutes(app) { 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, topic, !!can_publish, !!can_subscribe, svc, svc ? encrypt(password) : null] + [name.trim(), username, rolename, topics.join('\n'), !!can_publish, !!can_subscribe, svc, svc ? encrypt(password) : null] ) 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 + // 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' }) + + const { topics: newTopics, error: topicError } = parseTopics(req.body?.add_topic_scope) + if (topicError) return reply.status(400).send({ error: topicError }) + const existing = new Set(row.topic_scope.split('\n')) + const toAdd = newTopics.filter(t => !existing.has(t)) + if (!toAdd.length) return row // nothing new — no-op, not an error + + try { + await addTopicsToRole(row.rolename, toAdd, { canPublish: row.can_publish, canSubscribe: row.can_subscribe }) + } catch (e) { + return reply.status(502).send({ error: `Broker error: ${e.message}` }) + } + + const { rows: [updated] } = await pool.query( + `UPDATE mqtt_clients SET topic_scope = $1 WHERE id = $2 RETURNING ${CLIENT_COLS}`, + [[...existing, ...toAdd].join('\n'), req.params.id] + ) + return updated + }) + // POST /settings/api/mqtt-clients/:id/revoke — deletes the broker identity, // keeps the DB row (marked inactive) for an audit trail. app.post('/mqtt-clients/:id/revoke', { preHandler: requireAdmin }, async (req, reply) => {