Files
mesh-catalog/modules/mosquitto/provisioner/index.ts
T
jschoubben 1080f45012 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.
2026-09-30 13:01:14 +02:00

70 lines
3.5 KiB
TypeScript

// mosquitto's provisioner — the adapter that makes mosquitto a provider of the mesh `mqtt-topic`
// interface. The reconcile loop, the contributions file, and reading the mesh's minted password are
// the sdk harness's; this writes only the per-service half: how mosquitto creates and removes a
// per-consumer MQTT client (novox/hq ADR 0039/0040/0048).
//
// The `mqtt-topic` interface: a consumer connects as `as` with the password the mesh minted, and
// publishes and subscribes under `<as>/#`, isolated from every other consumer by a Dynamic Security
// 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.
// mosquitto creates exactly that client with exactly that password — a name or password the
// provisioner invented is one the consumer could never present.
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();
/** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */
async function announce(type: string, body: Record<string, string>): Promise<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[provisioner:mqtt-topic] emit ${type} failed: ${err}`);
}
}
runProvisioner("mqtt-topic", {
async create(p: Provision): Promise<void> {
// 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: granted.own ? p.as : "",
topics: granted.filters.join(" "),
});
},
async remove(p: { as: string }): Promise<void> {
await mosquitto.deleteScopedClient(p.as);
await announce("topic.deprovisioned", { username: p.as });
},
// 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<boolean> {
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);
},
});