diff --git a/modules/mosquitto/Dockerfile b/modules/mosquitto/Dockerfile index cdd07b1..fddaa44 100644 --- a/modules/mosquitto/Dockerfile +++ b/modules/mosquitto/Dockerfile @@ -13,7 +13,7 @@ ARG RUNTIME_BASE FROM ${BUILD_BASE} AS build WORKDIR /app/modules/mosquitto COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts \ +RUN node /app/node_modules/typescript/bin/tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts \ --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist FROM ${RUNTIME_BASE} diff --git a/modules/mosquitto/client.ts b/modules/mosquitto/client.ts index d6fb10e..4658662 100644 --- a/modules/mosquitto/client.ts +++ b/modules/mosquitto/client.ts @@ -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 `/#` 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, `/#` (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 { + async createScopedClient(username: string, password: string, filters: readonly string[]): Promise { 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 (`/#`, 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 { + async holdsClient(username: string, password: string, filters: readonly string[]): Promise { 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. */ diff --git a/modules/mosquitto/module.json b/modules/mosquitto/module.json index 7d85124..8c5511d 100644 --- a/modules/mosquitto/module.json +++ b/modules/mosquitto/module.json @@ -20,7 +20,10 @@ "mosquitto.topic.deprovisioned" ], "serves": { - "mqtt-topic": {} + "mqtt-topic": { + "scheme": "mqtt", + "port": 1883 + } }, "receives": { "mqtt-topic": "${dir:grants}/mesh.json" diff --git a/modules/mosquitto/package.json b/modules/mosquitto/package.json index 156253d..4c74b1f 100644 --- a/modules/mosquitto/package.json +++ b/modules/mosquitto/package.json @@ -1,9 +1,14 @@ { "name": "@novox/module-mosquitto", "version": "0.1.0", - "description": "mosquitto — provides the mesh mqtt-topic interface. Its admin client, provisioner, tools and events live here (novox/hq ADR 0039).", + "description": "mosquitto \u2014 provides the mesh mqtt-topic interface. Its admin client, provisioner, tools and events live here (novox/hq ADR 0039).", "type": "module", "private": true, + "scripts": { + "build": "tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", + "typecheck": "tsc -p tsconfig.json", + "test": "node --test --experimental-strip-types 'test/*.test.ts'" + }, "dependencies": { "@novox/mesh-sdk": "^0.1.1" }, diff --git a/modules/mosquitto/provisioner/index.ts b/modules/mosquitto/provisioner/index.ts index 8a5fa08..f7a5452 100644 --- a/modules/mosquitto/provisioner/index.ts +++ b/modules/mosquitto/provisioner/index.ts @@ -5,7 +5,14 @@ // // The `mqtt-topic` interface: a consumer connects as `as` with the password the mesh minted, and // publishes and subscribes under `/#`, isolated from every other consumer by a Dynamic Security -// role scoped to exactly that subtree. +// role scoped to exactly that subtree — unless it contributed `topics`, the MQTT topic filters its +// work needs (a home-automation hub needs the devices' topics); then the role grants exactly those +// (topics.ts). A list that is not valid topic filters is refused, and the consumer is not created +// or changed until it is fixed. +// +// What a consumer is told (its binding): `at` — the broker's machine — and `port`, the machine port +// of the MQTT listener (the manifest's `serves`); `as` is its login, and its copy of the password is +// the pair credential the mesh delivers to it. // // **The login and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives the // login and hands it to both ends so they agree, and mints the password and delivers a copy to each. @@ -15,6 +22,7 @@ import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { emit } from "@novox/mesh-sdk/events"; import { MosquittoClient } from "../client.js"; +import { topicFilters } from "../topics.js"; const mosquitto = MosquittoClient.fromEnv(); @@ -29,13 +37,20 @@ async function announce(type: string, body: Record): Promise { - // The topic subtree is scoped to the consumer's own login, so one cannot read another's topics. - const topicPrefix = p.as; - await mosquitto.createScopedClient(p.as, p.password, topicPrefix); + // By default the consumer's own subtree, so one cannot read another's topics; what it + // contributed as `topics` otherwise. + const granted = topicFilters(p.values, p.as); + if ("problem" in granted) { + // Thrown, so the harness logs it and retries: the consumer stays as it was (or absent) until + // its contribution is valid, rather than being given a grant it did not ask for. + throw new Error(`${p.as}: ${granted.problem}`); + } + await mosquitto.createScopedClient(p.as, p.password, granted.filters); await announce("topic.provisioned", { consumer: p.consumer ?? "", username: p.as, - topicPrefix, + topicPrefix: granted.own ? p.as : "", + topics: granted.filters.join(" "), }); }, @@ -46,6 +61,9 @@ runProvisioner("mqtt-topic", { // Asked every minute by the harness: whether the backend still holds this consumer exactly as // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). async holds(p: Provision): Promise { - return mosquitto.holdsClient(p.as, p.password); + const granted = topicFilters(p.values, p.as); + // An invalid list was never applied; create refuses it again, loudly, on every pass. + if ("problem" in granted) return false; + return mosquitto.holdsClient(p.as, p.password, granted.filters); }, }); diff --git a/modules/mosquitto/test/topics.test.ts b/modules/mosquitto/test/topics.test.ts new file mode 100644 index 0000000..e462b99 --- /dev/null +++ b/modules/mosquitto/test/topics.test.ts @@ -0,0 +1,72 @@ +// What a consumer of mqtt-topic is granted (topics.ts): its own subtree unless it contributed +// `topics`; a contributed list is granted exactly, refused whole when it is not topic filters; and +// the role is brought to exactly the wanted ACLs — missing ones added, stale ones removed — read from +// `mosquitto_ctrl dynsec getRole` as eclipse-mosquitto 2.1.2 prints it. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { filterProblem, missingAcls, parseRoleAcls, staleAcls, topicFilters, wantedAcls } from "../topics.ts"; + +test("a consumer that contributed nothing gets its own subtree", () => { + assert.deepEqual(topicFilters({}, "mesh_ace_hass"), { ok: true, filters: ["mesh_ace_hass/#"], own: true }); + assert.deepEqual(topicFilters(undefined, "x"), { ok: true, filters: ["x/#"], own: true }); + // Settings merge into every contribution: keys that are not `topics` change nothing. + assert.deepEqual(topicFilters({ endpoints: { web: {} } }, "x"), { ok: true, filters: ["x/#"], own: true }); +}); + +test("a contributed list is granted exactly, duplicates once", () => { + assert.deepEqual(topicFilters({ topics: ["#"] }, "x"), { ok: true, filters: ["#"], own: false }); + assert.deepEqual(topicFilters({ topics: ["stat/+/POWER", "tele/#", "tele/#", "/octoprint/x"] }, "x"), { + ok: true, + filters: ["stat/+/POWER", "tele/#", "/octoprint/x"], + own: false, + }); +}); + +test("a list that is not topic filters is refused whole", () => { + for (const topics of [[], "#", [""], ["a/#/b"], ["a#"], ["a/b+"], [42], ["a\u0000b"], {}]) { + const out = topicFilters({ topics } as Record, "x"); + assert.equal(out.ok, false, JSON.stringify(topics)); + } + assert.equal(filterProblem("+/+/#"), undefined); + assert.equal(filterProblem("#"), undefined); +}); + +const GET_ROLE = `Warning: You are running mosquitto_ctrl without encryption. +This means all of the configuration changes you are making are visible on the network, including passwords. + +Rolename: u1 +ACLs: publishClientSend : allow : # (priority: 0) + subscribePattern : allow : u1/# (priority: 0) + publishClientReceive : deny : secret topic/with space (priority: -1) +`; + +test("getRole's ACL lines are read, the warning and headings are not", () => { + assert.deepEqual(parseRoleAcls(GET_ROLE), [ + { type: "publishClientSend", allow: true, topic: "#" }, + { type: "subscribePattern", allow: true, topic: "u1/#" }, + { type: "publishClientReceive", allow: false, topic: "secret topic/with space" }, + ]); + assert.deepEqual(parseRoleAcls("Rolename: empty\nACLs:\n"), []); +}); + +test("the role is brought to exactly the wanted ACLs", () => { + const current = parseRoleAcls(GET_ROLE); + const wanted = wantedAcls(["u1/#"]); + assert.deepEqual(wanted, [ + { type: "publishClientSend", allow: true, topic: "u1/#" }, + { type: "publishClientReceive", allow: true, topic: "u1/#" }, + { type: "subscribePattern", allow: true, topic: "u1/#" }, + ]); + assert.deepEqual(missingAcls(current, wanted), [ + { type: "publishClientSend", allow: true, topic: "u1/#" }, + { type: "publishClientReceive", allow: true, topic: "u1/#" }, + ]); + assert.deepEqual(staleAcls(current, wanted), [ + { type: "publishClientSend", allow: true, topic: "#" }, + { type: "publishClientReceive", allow: false, topic: "secret topic/with space" }, + ]); + assert.deepEqual(staleAcls(wanted, wanted), []); + assert.deepEqual(missingAcls(wanted, wanted), []); +}); diff --git a/modules/mosquitto/topics.ts b/modules/mosquitto/topics.ts new file mode 100644 index 0000000..fdecdb1 --- /dev/null +++ b/modules/mosquitto/topics.ts @@ -0,0 +1,107 @@ +// Which topics a consumer of `mqtt-topic` may use — the one choice a consumer makes about its grant. +// +// **By default, its own subtree and nothing else.** A consumer connects as the login the mesh derived +// (`as`) and may publish, receive and subscribe under `/#` — isolated from every other consumer, +// which is the point of a per-consumer client (novox/hq ADR 0039/0048). +// +// **A consumer whose work IS the shared topic space says so.** Home Assistant discovers devices +// under `homeassistant/#` and `tasmota/discovery/#` and follows whatever state topics they announce; +// Node-RED's flows subscribe to the topics devices publish on (`stat//POWER`, …). Confined +// to `/#` neither could do its job. So a consumer contributes `topics` to its `mqtt-topic` +// requirement — a list of MQTT topic filters — and the provisioner grants exactly those, both ways. +// Because assignment settings merge into every contribution, an operator narrows (or widens) the +// list per machine with the same key, without editing a manifest. +// +// Pure, so it is tested without a broker (test/topics.test.ts). + +/** The dynsec ACL types a granted filter carries: send to it, receive from it, subscribe to it. */ +export const GRANTED_ACL_TYPES = ["publishClientSend", "publishClientReceive", "subscribePattern"] as const; + +/** One ACL on a role, as `mosquitto_ctrl dynsec getRole` reports it. */ +export interface Acl { + type: string; + allow: boolean; + topic: string; +} + +export type Filters = { ok: true; filters: string[]; own: boolean } | { ok: false; problem: string }; + +/** + * The topic filters a consumer is granted: what it contributed as `topics`, or its own subtree when + * it contributed nothing. Refused — never silently narrowed or widened — when the list is not a + * list of valid MQTT topic filters: a grant that quietly differs from what was asked is a consumer + * that fails somewhere far from the cause. + */ +export function topicFilters(values: Readonly> | undefined, as: string): Filters { + const given = values?.topics; + if (given === undefined || given === null) { + return { ok: true, filters: [`${as}/#`], own: true }; + } + if (!Array.isArray(given) || given.length === 0) { + return { ok: false, problem: `topics must be a non-empty list of MQTT topic filters, not ${JSON.stringify(given)}` }; + } + const out: string[] = []; + for (const f of given) { + if (typeof f !== "string") { + return { ok: false, problem: `topics holds ${JSON.stringify(f)}, which is not a topic filter` }; + } + const problem = filterProblem(f); + if (problem) return { ok: false, problem: `topic filter ${JSON.stringify(f)}: ${problem}` }; + if (!out.includes(f)) out.push(f); + } + return { ok: true, filters: out, own: out.length === 1 && out[0] === `${as}/#` }; +} + +/** Why a string is not a valid MQTT topic filter (MQTT 3.1.1 §4.7), or undefined when it is one. */ +export function filterProblem(filter: string): string | undefined { + if (filter.length === 0) return "it is empty"; + if (Buffer.byteLength(filter, "utf8") > 65535) return "it is longer than MQTT allows"; + if (filter.includes("\u0000")) return "it contains a NUL character"; + const levels = filter.split("/"); + for (let i = 0; i < levels.length; i++) { + const level = levels[i]; + if (level.includes("#") && (level !== "#" || i !== levels.length - 1)) { + return "'#' must be a whole level, and the last one"; + } + if (level.includes("+") && level !== "+") return "'+' must be a whole level"; + } + return undefined; +} + +/** The ACLs a role must carry to grant these filters: every granted type, allowed, on every filter. */ +export function wantedAcls(filters: readonly string[]): Acl[] { + const out: Acl[] = []; + for (const topic of filters) { + for (const type of GRANTED_ACL_TYPES) out.push({ type, allow: true, topic }); + } + return out; +} + +/** + * The ACLs `mosquitto_ctrl dynsec getRole` lists, one per line under its "ACLs:" heading: + * `ACLs: publishClientSend : allow : # (priority: 0)` + * ` subscribePattern : allow : u1/# (priority: 0)` + */ +export function parseRoleAcls(output: string): Acl[] { + const out: Acl[] = []; + const line = /^(?:ACLs:)?\s*([A-Za-z]+)\s*:\s*(allow|deny)\s*:\s*(.*?)\s+\(priority:\s*-?\d+\)\s*$/; + for (const raw of output.split(/\r?\n/)) { + const m = raw.match(line); + if (m) out.push({ type: m[1], allow: m[2] === "allow", topic: m[3] }); + } + return out; +} + +const key = (a: Acl): string => `${a.type}\u0000${a.allow ? "allow" : "deny"}\u0000${a.topic}`; + +/** ACLs a role carries that it should not: in `current` and not in `wanted`. */ +export function staleAcls(current: readonly Acl[], wanted: readonly Acl[]): Acl[] { + const want = new Set(wanted.map(key)); + return current.filter((a) => !want.has(key(a))); +} + +/** ACLs a role should carry and does not. */ +export function missingAcls(current: readonly Acl[], wanted: readonly Acl[]): Acl[] { + const have = new Set(current.map(key)); + return wanted.filter((a) => !have.has(key(a))); +} diff --git a/modules/mosquitto/tsconfig.json b/modules/mosquitto/tsconfig.json index 95f347f..dc1f46b 100644 --- a/modules/mosquitto/tsconfig.json +++ b/modules/mosquitto/tsconfig.json @@ -8,5 +8,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts", "bootstrap/index.ts"] + "include": ["topics.ts", "client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts", "bootstrap/index.ts"] }