Add self-service MQTT Broker Clients management

New /settings/api/mqtt-clients routes (list/create/revoke/delete) manage
per-consumer dynamic-security identities on the shared broker (LXC 104) —
name + topic scope + publish/subscribe flags in, generated username/password
out (shown once, never stored). Replaces manually running mosquitto_ctrl
over SSH by hand for every new device or app that needs broker access.

Implementation SSHs into the broker LXC and runs mosquitto_ctrl inside a
throwaway container on its Docker network (src/lib/ssh.js + mqtt-dynsec.js)
rather than reimplementing the dynamic-security plugin's JSON wire protocol
from scratch — reuses the exact commands verified by hand while wiring up
the water-softener and mqtt-inspector clients this session. Needs the
shared deploy SSH key mounted (Dockerfile/compose changes) and
MQTT_ADMIN_USER/PASS threaded in via stack-init.

mqtt_clients table is bookkeeping only (name/scope/active) — the broker's
own dynamic-security.json remains the source of truth for auth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-28 17:11:35 +00:00
parent 221be89667
commit 0bdfe67bf5
7 changed files with 196 additions and 0 deletions

View file

@ -0,0 +1,84 @@
import crypto from 'crypto'
import { pool } from '../db.js'
import { requireAdmin } from '../auth.js'
import { createDynsecClient, deleteDynsecClient, slugify, randomPassword } from '../lib/mqtt-dynsec.js'
const TOPIC_RE = /^[a-zA-Z0-9/_+#-]+$/
export async function mqttClientRoutes(app) {
// GET /settings/api/mqtt-clients
app.get('/mqtt-clients', { preHandler: requireAdmin }, async () => {
const { rows } = await pool.query('SELECT * FROM mqtt_clients ORDER BY created_at DESC')
return rows
})
// POST /settings/api/mqtt-clients — creates the broker identity, returns the
// password once. It is never persisted anywhere after this response.
app.post('/mqtt-clients', { preHandler: requireAdmin }, async (req, reply) => {
const { name, topic_scope, can_publish, can_subscribe } = req.body || {}
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, / _ + - #' })
}
if (!can_publish && !can_subscribe) {
return reply.status(400).send({ error: 'At least one of publish or subscribe must be enabled' })
}
const username = `${slugify(name)}-${crypto.randomBytes(2).toString('hex')}`
const rolename = `${username}-role`
const password = randomPassword()
try {
await createDynsecClient({
username, password, rolename, topic,
canPublish: !!can_publish, canSubscribe: !!can_subscribe,
})
} catch (e) {
// Best-effort cleanup of whatever partially got created before the failure.
deleteDynsecClient(username, rolename).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)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[name.trim(), username, rolename, topic, !!can_publish, !!can_subscribe]
)
return { ...row, password }
})
// 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) => {
const { rows: [row] } = await pool.query('SELECT * FROM mqtt_clients WHERE id = $1', [req.params.id])
if (!row) return reply.status(404).send({ error: 'Not found' })
if (!row.active) return row
try {
await deleteDynsecClient(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 active = false, revoked_at = NOW() WHERE id = $1 RETURNING *`,
[req.params.id]
)
return updated
})
// DELETE /settings/api/mqtt-clients/:id — removes the audit row entirely;
// revokes on the broker first if it was somehow still active.
app.delete('/mqtt-clients/:id', { preHandler: requireAdmin }, async (req, reply) => {
const { rows: [row] } = await pool.query('SELECT * FROM mqtt_clients WHERE id = $1', [req.params.id])
if (!row) return reply.status(404).send({ error: 'Not found' })
if (row.active) {
await deleteDynsecClient(row.username, row.rolename).catch(() => {})
}
await pool.query('DELETE FROM mqtt_clients WHERE id = $1', [req.params.id])
return { ok: true }
})
}