mosquitto: a consumer's grant is the topics it asks for, and its binding says the port

mqtt-topic served nothing: with two listens the mesh could not say which port a consumer dials, so a
consumer had to type 1883 into its config. It now serves the MQTT listener's port (the machine's,
once assigned) and the scheme, so `${bound:mqtt-topic:port}` fills.

The provisioner confined every consumer to `<as>/#`, which leaves nothing for the consumers the
broker exists for: Home Assistant discovers under homeassistant/# and tasmota/discovery/#, and
Node-RED's flows follow the devices' own topics. A consumer now contributes `topics` (MQTT topic
filters) to its mqtt-topic requirement and is granted exactly those; with none, its own subtree as
before. Settings merge into contributions, so an operator narrows a grant per assignment. The role
is brought to exactly the wanted ACLs (stale ones removed), `holds` checks the ACLs too, and an
invalid list is refused, never quietly narrowed. Only the role named for the consumer is touched:
a client carried from the predecessor's password file keeps its own.
This commit is contained in:
2026-09-30 13:01:14 +02:00
parent a32394ec22
commit 1080f45012
8 changed files with 253 additions and 34 deletions
+38 -24
View File
@@ -20,6 +20,8 @@ import { readFileSync } from "node:fs";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { missingAcls, parseRoleAcls, staleAcls, wantedAcls } from "./topics.js";
const run = promisify(execFile);
export interface MqttConn {
@@ -141,14 +143,19 @@ export class MosquittoClient {
}
/**
* Create (or reset to a known state) a client scoped to one topic namespace, idempotently. The
* client is confined to `<prefix>/#` by a same-named role: it may publish to, subscribe to and
* receive on exactly its own subtree and nothing else — the MQTT analog of redis's keyspace-scoped
* ACL user. Called again for an existing client, it resets the password and re-asserts the ACLs.
* Create (or reset to a known state) a client granted exactly these topic filters, idempotently.
* The grant is a same-named role carrying, for every filter, publish, receive and subscribe — and
* nothing else: an ACL the role carries that the filters no longer name is removed, so narrowing a
* consumer's `topics` narrows what it may do. By default the filters are the consumer's own
* subtree, `<as>/#` (see topics.ts). Called again for an existing client, it resets the password
* and re-asserts the ACLs.
*
* Only the role named for this client is ever changed. A client or role the mesh did not make —
* a device carried from the predecessor's password file, its `legacy-full-access` role — is never
* read, changed or removed here.
*/
async createScopedClient(username: string, password: string, topicPrefix: string): Promise<void> {
async createScopedClient(username: string, password: string, filters: readonly string[]): Promise<void> {
const role = username; // one role per client, named for it
const pattern = `${topicPrefix}/#`;
if (await this.clientExists(username)) {
await this.ctl("setClientPassword", username, password);
@@ -161,17 +168,18 @@ export class MosquittoClient {
await this.ctl("createClient", username, "-p", password);
}
// A role carrying exactly this client's topic ACLs. createRole, addRoleACL and addClientRole are
// all one-shot: each rejects with an "already exists" when re-run against a role/ACL/binding it
// created on a previous reconcile. That rejection is the intended terminal state — the ACL is
// deterministic (`<prefix>/#`, allow), so re-adding the identical entry is a no-op — so it is
// swallowed. (Until the exit code was fixed this was invisible: the tool returned 0 and the
// rejection was lost; now it surfaces, and each of these adds must tolerate its own idempotent
// re-run explicitly.)
// createRole and addRoleACL are one-shot: each rejects with an "already exists" when re-run
// against a role/ACL it created on a previous reconcile. That rejection is the intended terminal
// state, so it is swallowed.
await ignoreExisting(this.ctl("createRole", role));
for (const acl of ["publishClientSend", "publishClientReceive", "subscribePattern"]) {
// allow (1) this client to send to, receive on, and subscribe under its own subtree.
await ignoreExisting(this.ctl("addRoleACL", role, acl, pattern, "allow"));
const wanted = wantedAcls(filters);
const current = parseRoleAcls(await this.ctl("getRole", role));
for (const acl of missingAcls(current, wanted)) {
await ignoreExisting(this.ctl("addRoleACL", role, acl.type, acl.topic, "allow"));
}
// What the consumer no longer asks for — added before it narrowed its topics — is taken away.
for (const acl of staleAcls(current, wanted)) {
await ignoreMissing(this.ctl("removeRoleACL", role, acl.type, acl.topic));
}
// Bind the role only when it is not already bound — addClientRole is the one call whose
// idempotent re-run cannot be recognised by message (see clientHasRole).
@@ -181,25 +189,31 @@ export class MosquittoClient {
}
/**
* Whether a consumer's client accepts exactly this password and still carries its own role.
* Read-only. The password is checked the way the consumer is checked, by an MQTT CONNECT as it,
* and the broker's CONNACK code is the answer: 0 accepted, 4 bad credentials, 5 not authorised.
* Nothing rides on argv. An unreachable broker rejects (novox/hq issue 120).
* Whether a consumer's client accepts exactly this password, still carries its own role, and that
* role grants exactly these filters. Read-only. The password is checked the way the consumer is
* checked, by an MQTT CONNECT as it, and the broker's CONNACK code is the answer: 0 accepted,
* 4 bad credentials, 5 not authorised. Nothing rides on argv. An unreachable broker rejects
* (novox/hq issue 120).
*/
async holdsClient(username: string, password: string): Promise<boolean> {
async holdsClient(username: string, password: string, filters: readonly string[]): Promise<boolean> {
const code = await mqttConnack(this.conn.host, this.conn.port, username, password);
if (code === 4 || code === 5) return false;
if (code !== 0) throw new Error(`mosquitto refused ${username} with CONNACK ${code}`);
// The role, asked directly: only "not found" means absent. Any other failure to ask rejects,
// unlike clientHasRole, which reads every failure as "no role".
let out: string;
let client: string;
let role: string;
try {
out = await this.ctl("getClient", username);
client = await this.ctl("getClient", username);
role = await this.ctl("getRole", username);
} catch (err) {
if (/not\s*found|does not exist|no such/i.test(String(err))) return false;
throw err;
}
return new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(out);
if (!new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(client)) return false;
const current = parseRoleAcls(role);
const wanted = wantedAcls(filters);
return missingAcls(current, wanted).length === 0 && staleAcls(current, wanted).length === 0;
}
/** Remove a client and the per-client role created for it, idempotently. */