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:
+38
-24
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user