settings: serve MQTT credentials to stack apps via service clients
MQTT Broker Clients gained a 'service consumer' flag. Device clients stay show-once (a human copies the password into hardware). Service clients (a stack app like hvac-backend that must re-read its own broker login on every restart) get a STABLE username (= name slug, no random suffix) and their password stored AES-256-GCM encrypted (crypto.js, same as integration secrets), served at runtime via: GET /settings/api/internal/mqtt-client/:username (bearer SETTINGS_SECRET) This is the credential path the hvac plan always intended — show-once genuinely can't feed a machine consumer. Revoke/delete drop the stored secret; the internal endpoint refuses revoked clients; secret_enc is never returned to the admin UI. Reject duplicate usernames up front with 409. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b7a8c31988
commit
86f03aac0b
2 changed files with 62 additions and 12 deletions
15
src/db.js
15
src/db.js
|
|
@ -21,9 +21,14 @@ export async function initDb() {
|
|||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Bookkeeping only — the broker's own dynamic-security.json is the source
|
||||
-- of truth for auth. Password is never stored here, only shown once at
|
||||
-- creation (same pattern as forecasting's api_keys table).
|
||||
-- Broker logins scoped to a topic filter. The broker's own
|
||||
-- dynamic-security.json is the source of truth for auth. For DEVICE clients
|
||||
-- the password is show-once (never stored) — a human copies it into hardware.
|
||||
-- For SERVICE clients (is_service=true, a stack app like hvac-backend that
|
||||
-- must re-read its own login on every restart) the password IS stored,
|
||||
-- AES-256-GCM encrypted (crypto.js, same as integration secrets), and served
|
||||
-- to that app at runtime via GET /internal/mqtt-client/:username. This is the
|
||||
-- only way a machine consumer can work — show-once can't be re-fetched.
|
||||
CREATE TABLE IF NOT EXISTS mqtt_clients (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
|
|
@ -38,6 +43,10 @@ export async function initDb() {
|
|||
);
|
||||
`)
|
||||
|
||||
// Service-consumer support — added after the table shipped, so ALTER-idempotent.
|
||||
await pool.query(`ALTER TABLE mqtt_clients ADD COLUMN IF NOT EXISTS is_service BOOLEAN NOT NULL DEFAULT false`)
|
||||
await pool.query(`ALTER TABLE mqtt_clients ADD COLUMN IF NOT EXISTS secret_enc TEXT`)
|
||||
|
||||
// Seed known integrations (idempotent — never overwrites existing config/secrets)
|
||||
const slugs = Object.entries(INTEGRATIONS).map(([slug, { name }]) => ({ slug, name }))
|
||||
for (const { slug, name } of slugs) {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,51 @@
|
|||
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'
|
||||
|
||||
const TOPIC_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'
|
||||
|
||||
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')
|
||||
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, 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 || {}
|
||||
const { name, topic_scope, can_publish, can_subscribe, is_service } = req.body || {}
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
return reply.status(400).send({ error: 'Name is required' })
|
||||
}
|
||||
|
|
@ -27,10 +57,20 @@ export async function mqttClientRoutes(app) {
|
|||
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')}`
|
||||
// Service clients get a STABLE username = the name's slug (no random suffix),
|
||||
// so the consuming app can look its credential up by a known id (e.g. an app
|
||||
// named "hvac-backend" is reachable at /internal/mqtt-client/hvac-backend).
|
||||
// Device clients keep the random suffix to avoid collisions.
|
||||
const svc = !!is_service
|
||||
const username = svc ? slugify(name) : `${slugify(name)}-${crypto.randomBytes(2).toString('hex')}`
|
||||
const rolename = `${username}-role`
|
||||
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, rolename, topic,
|
||||
|
|
@ -43,9 +83,9 @@ export async function mqttClientRoutes(app) {
|
|||
}
|
||||
|
||||
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]
|
||||
`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]
|
||||
)
|
||||
return { ...row, password }
|
||||
})
|
||||
|
|
@ -53,7 +93,7 @@ export async function mqttClientRoutes(app) {
|
|||
// 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])
|
||||
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
|
||||
|
||||
|
|
@ -63,8 +103,9 @@ export async function mqttClientRoutes(app) {
|
|||
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() WHERE id = $1 RETURNING *`,
|
||||
`UPDATE mqtt_clients SET active = false, revoked_at = NOW(), secret_enc = NULL WHERE id = $1 RETURNING ${CLIENT_COLS}`,
|
||||
[req.params.id]
|
||||
)
|
||||
return updated
|
||||
|
|
@ -73,7 +114,7 @@ export async function mqttClientRoutes(app) {
|
|||
// 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])
|
||||
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(() => {})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue