settings: MQTT clients support multiple topic scopes on one connection
A single client sometimes legitimately needs several unrelated topic trees (e.g. hvac-backend needs both shellies/# for TRVs and hvac/mhi/# for MHI aircon) — the previous model only supported one topic_scope per client, forcing either an overbroad '#' grant or multiple broker logins for one app. topic_scope is now newline/comma-parsed into a list; each pattern gets its own ACL grant on the client's role (createDynsecClient loops per topic). Stored newline-joined (still a single TEXT column, no migration). Added PATCH /mqtt-clients/:id (additive: add_topic_scope) to grant a client more topics later without revoking/recreating it — backed by a new addTopicsToRole() in mqtt-dynsec.js. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
86f03aac0b
commit
9fce62525d
2 changed files with 79 additions and 16 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue