// 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))); }