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>
207 lines
10 KiB
JavaScript
207 lines
10 KiB
JavaScript
import { pool } from '../db.js'
|
|
import { requireAdmin } from '../auth.js'
|
|
import { encrypt, decrypt } from '../crypto.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'
|
|
const MQTT_BROKER_PORT = Number(process.env.MQTT_BROKER_PORT) || 1883
|
|
|
|
// Never leak secret_enc to the admin UI — it's only ever decrypted for the
|
|
// 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 () => {
|
|
const { rows } = await pool.query(`SELECT ${CLIENT_COLS} FROM mqtt_clients ORDER BY created_at DESC`)
|
|
return rows
|
|
})
|
|
|
|
// GET /settings/api/internal/mqtt-client/:username — service-to-service, no
|
|
// user session. Authenticated by SETTINGS_SECRET bearer token (same as the
|
|
// integration internal endpoint). Returns the decrypted broker login for an
|
|
// ACTIVE service client so a stack app (e.g. hvac-backend) can fetch its own
|
|
// credential at runtime. Device clients / revoked clients are never served.
|
|
app.get('/internal/mqtt-client/:username', async (req, reply) => {
|
|
const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
|
|
if (!token || token !== process.env.SETTINGS_SECRET) {
|
|
return reply.status(401).send({ error: 'Unauthorized' })
|
|
}
|
|
const { rows: [row] } = await pool.query('SELECT * FROM mqtt_clients WHERE username = $1', [req.params.username])
|
|
if (!row || !row.is_service || !row.secret_enc) {
|
|
return reply.status(404).send({ error: 'No service credential for this client' })
|
|
}
|
|
if (!row.active) return reply.status(403).send({ error: 'Client is revoked' })
|
|
return {
|
|
host: MQTT_BROKER_HOST,
|
|
port: MQTT_BROKER_PORT,
|
|
username: row.username,
|
|
password: decrypt(row.secret_enc),
|
|
}
|
|
})
|
|
|
|
// 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, is_service } = req.body || {}
|
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
return reply.status(400).send({ error: 'Name is required' })
|
|
}
|
|
|
|
// 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])
|
|
if (dup) {
|
|
return reply.status(409).send({ error: `A client with username "${username}" already exists — revoke/delete it first, or choose a different name.` })
|
|
}
|
|
|
|
try {
|
|
await createDynsecClient({ username, password })
|
|
} catch (e) {
|
|
// Best-effort cleanup of whatever partially got created before the failure.
|
|
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, '#', true, true, $4, $5) RETURNING ${CLIENT_COLS}`,
|
|
[name.trim(), username, OPEN_ROLE, !!is_service, encrypt(password)]
|
|
)
|
|
return { ...row, password }
|
|
})
|
|
|
|
// 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 })
|
|
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) => {
|
|
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 row
|
|
|
|
try {
|
|
await deleteDynsecClient(row.username, row.rolename)
|
|
} catch (e) {
|
|
return reply.status(502).send({ error: `Broker error: ${e.message}` })
|
|
}
|
|
|
|
// Drop the stored secret too — a revoked client must never be servable.
|
|
const { rows: [updated] } = await pool.query(
|
|
`UPDATE mqtt_clients SET active = false, revoked_at = NOW(), secret_enc = NULL WHERE id = $1 RETURNING ${CLIENT_COLS}`,
|
|
[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 ${CLIENT_COLS} 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 }
|
|
})
|
|
}
|