diff --git a/src/db.js b/src/db.js index 2eb30b9..e0e8d09 100644 --- a/src/db.js +++ b/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) { diff --git a/src/routes/mqtt-clients.js b/src/routes/mqtt-clients.js index 96c6577..d96f6a2 100644 --- a/src/routes/mqtt-clients.js +++ b/src/routes/mqtt-clients.js @@ -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(() => {})