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.
70 lines
3.5 KiB
TypeScript
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);
|
|
},
|
|
});
|