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 <noreply@anthropic.com>
This commit is contained in:
parent
704c1aa7fa
commit
e74308f5e2
3 changed files with 115 additions and 56 deletions
16
src/db.js
16
src/db.js
|
|
@ -21,14 +21,14 @@ export async function initDb() {
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Broker logins scoped to a topic filter. The broker's own
|
-- Broker logins. The broker's own dynamic-security.json is the source of
|
||||||
-- dynamic-security.json is the source of truth for auth. For DEVICE clients
|
-- truth for auth. New clients join the shared 'open' role (all topics) and
|
||||||
-- the password is show-once (never stored) — a human copies it into hardware.
|
-- every password is stored AES-256-GCM encrypted (crypto.js, same as
|
||||||
-- For SERVICE clients (is_service=true, a stack app like hvac-backend that
|
-- integration secrets) so an admin can reveal it and reuse it across a batch
|
||||||
-- must re-read its own login on every restart) the password IS stored,
|
-- of devices. Legacy clients created before this have secret_enc NULL until
|
||||||
-- AES-256-GCM encrypted (crypto.js, same as integration secrets), and served
|
-- reset, and may still carry a per-client topic-scoped role. SERVICE clients
|
||||||
-- to that app at runtime via GET /internal/mqtt-client/:username. This is the
|
-- (is_service=true, e.g. hvac-backend) are additionally served to that app at
|
||||||
-- only way a machine consumer can work — show-once can't be re-fetched.
|
-- runtime via GET /internal/mqtt-client/:username.
|
||||||
CREATE TABLE IF NOT EXISTS mqtt_clients (
|
CREATE TABLE IF NOT EXISTS mqtt_clients (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -37,28 +37,46 @@ export function randomPassword() {
|
||||||
return crypto.randomBytes(24).toString('base64').replace(/[^a-zA-Z0-9]/g, '').slice(0, 18)
|
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
|
// Shared role every new client joins. Pub/sub on '#' (which never matches
|
||||||
// trees on one connection (e.g. hvac-backend needs both hvac/mhi/# and
|
// $-prefixed topics, so the $CONTROL dynsec admin channel stays protected) —
|
||||||
// shellies/#). Each pattern gets its own ACL grant on the same role.
|
// the broker login itself is the only gate, so one credential can be reused
|
||||||
export async function createDynsecClient({ username, password, rolename, topics, canPublish, canSubscribe }) {
|
// across a whole batch of similar devices without per-client topic upkeep.
|
||||||
await dynsec('createClient', username, '-p', password)
|
export const OPEN_ROLE = 'open'
|
||||||
await dynsec('createRole', rolename)
|
|
||||||
for (const topic of topics) {
|
async function ignoreExists(promise) {
|
||||||
if (canPublish) {
|
try { await promise } catch (e) { if (!/already exists/i.test(e.message)) throw e }
|
||||||
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
|
export async function ensureOpenRole() {
|
||||||
// service client's needs grow later (e.g. hvac-backend gaining a new device
|
await ignoreExists(dynsec('createRole', OPEN_ROLE))
|
||||||
// family) without having to revoke/recreate the whole client.
|
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 }) {
|
export async function addTopicsToRole(rolename, topics, { canPublish, canSubscribe }) {
|
||||||
for (const topic of topics) {
|
for (const topic of topics) {
|
||||||
if (canPublish) {
|
if (canPublish) {
|
||||||
|
|
@ -74,6 +92,7 @@ export async function addTopicsToRole(rolename, topics, { canPublish, canSubscri
|
||||||
|
|
||||||
export async function deleteDynsecClient(username, rolename) {
|
export async function deleteDynsecClient(username, rolename) {
|
||||||
await dynsec('deleteClient', username)
|
await dynsec('deleteClient', username)
|
||||||
// Role deletion is best-effort — a role left behind is inert, not a leak.
|
// The shared open role must outlive any one client. Per-client legacy roles
|
||||||
await dynsec('deleteRole', rolename).catch(() => {})
|
// are deleted best-effort — a role left behind is inert, not a leak.
|
||||||
|
if (rolename !== OPEN_ROLE) await dynsec('deleteRole', rolename).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import crypto from 'crypto'
|
|
||||||
import { pool } from '../db.js'
|
import { pool } from '../db.js'
|
||||||
import { requireAdmin } from '../auth.js'
|
import { requireAdmin } from '../auth.js'
|
||||||
import { encrypt, decrypt } from '../crypto.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 TOPIC_CHAR_RE = /^[a-zA-Z0-9/_+#-]+$/
|
||||||
const MQTT_BROKER_HOST = process.env.MQTT_BROKER_HOST || '10.10.10.104'
|
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
|
// POST /settings/api/mqtt-clients — creates the broker identity on the shared
|
||||||
// password once. It is never persisted anywhere after this response.
|
// 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) => {
|
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()) {
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||||
return reply.status(400).send({ error: 'Name is required' })
|
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),
|
// Username is the name's slug — stable and memorable, so a service app can
|
||||||
// so the consuming app can look its credential up by a known id (e.g. an app
|
// look its credential up by a known id (e.g. "hvac-backend") and a device
|
||||||
// named "hvac-backend" is reachable at /internal/mqtt-client/hvac-backend).
|
// batch ("tasmota-lights") shares one login.
|
||||||
// Device clients keep the random suffix to avoid collisions.
|
const username = slugify(name)
|
||||||
const svc = !!is_service
|
|
||||||
const username = svc ? slugify(name) : `${slugify(name)}-${crypto.randomBytes(2).toString('hex')}`
|
|
||||||
const rolename = `${username}-role`
|
|
||||||
const password = randomPassword()
|
const password = randomPassword()
|
||||||
|
|
||||||
const { rows: [dup] } = await pool.query('SELECT id FROM mqtt_clients WHERE username = $1', [username])
|
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 {
|
try {
|
||||||
await createDynsecClient({
|
await createDynsecClient({ username, password })
|
||||||
username, password, rolename, topics,
|
|
||||||
canPublish: !!can_publish, canSubscribe: !!can_subscribe,
|
|
||||||
})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Best-effort cleanup of whatever partially got created before the failure.
|
// 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}` })
|
return reply.status(502).send({ error: `Broker error: ${e.message}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows: [row] } = await pool.query(
|
const { rows: [row] } = await pool.query(
|
||||||
`INSERT INTO mqtt_clients (name, username, rolename, topic_scope, can_publish, can_subscribe, is_service, secret_enc)
|
`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}`,
|
VALUES ($1, $2, $3, '#', true, true, $4, $5) RETURNING ${CLIENT_COLS}`,
|
||||||
[name.trim(), username, rolename, topics.join('\n'), !!can_publish, !!can_subscribe, svc, svc ? encrypt(password) : null]
|
[name.trim(), username, OPEN_ROLE, !!is_service, encrypt(password)]
|
||||||
)
|
)
|
||||||
return { ...row, password }
|
return { ...row, password }
|
||||||
})
|
})
|
||||||
|
|
||||||
// PATCH /settings/api/mqtt-clients/:id — add more topic scopes to an existing
|
// GET /settings/api/mqtt-clients/:id/password — admin reveal. Legacy clients
|
||||||
// client's role, e.g. when a service client's needs grow. Additive only — this
|
// 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).
|
// never removes an existing grant (revoke/delete the whole client for that).
|
||||||
app.patch('/mqtt-clients/:id', { preHandler: requireAdmin }, async (req, reply) => {
|
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])
|
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) return reply.status(404).send({ error: 'Not found' })
|
||||||
if (!row.active) return reply.status(400).send({ error: 'Client is revoked' })
|
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)
|
const { topics: newTopics, error: topicError } = parseTopics(req.body?.add_topic_scope)
|
||||||
if (topicError) return reply.status(400).send({ error: topicError })
|
if (topicError) return reply.status(400).send({ error: topicError })
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue