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:
@@ -5,7 +5,14 @@
|
||||
//
|
||||
// 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.
|
||||
// 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<string, string>): Promise<voi
|
||||
|
||||
runProvisioner("mqtt-topic", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
// 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<boolean> {
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user