nodered: its MQTT broker comes from the mesh
Node-RED's one broker node pointed at zurag.be:1884, where nothing listens. nodered now requires
mqtt-topic (asking for every topic: flows follow the devices' own) and a run-once `mqtt` step —
declared last, restarted when the binding, credential or settings change — points the mesh's broker
nodes at the bound broker through Node-RED's admin API with the module's api-token: the node the
step makes itself when none is named, or the ones an assignment names in `mqtt.brokers`. Only host,
port, TLS and the login change; the broker is asked first whether it takes the login; the deploy is
against the revision read ("nodes", so only that node restarts) and a digest makes a rerun a no-op.
A broker node nobody named is never touched. settings.js keeps `mqtt` and `topics` out of Node-RED.
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
// Node-RED's MQTT broker config node, pointed at the broker the mesh bound — `mqtt-topic`.
|
||||
//
|
||||
// **Why a step.** Node-RED keeps a broker as a config node in its flows (`flows.json`) and the login
|
||||
// and password in its encrypted credentials file, both of them Node-RED's to write. So this reads the
|
||||
// binding and the pair credential and makes the broker node say the same thing through Node-RED's
|
||||
// admin API — `GET /flows`, then `POST /flows` with the changed node and its `credentials`, deployed
|
||||
// as "nodes" so only what changed restarts — with the module's own `api-token`.
|
||||
//
|
||||
// **Which broker nodes are the mesh's.** Never guessed: a flow may talk to a broker that has nothing
|
||||
// to do with this mesh. The step owns the node it creates itself (id `mesh-mqtt-topic`, "mesh:
|
||||
// mqtt-topic") and the ones an assignment names in settings (`mqtt.brokers`: node ids — how ace's
|
||||
// existing broker node, which every one of its MQTT flows uses, is handed over). With none named and
|
||||
// none made yet, it makes one, so a fresh Node-RED has a broker the flows can pick.
|
||||
//
|
||||
// **Only the connection, and only when it differs.** Host, port, TLS off (the broker serves plain
|
||||
// MQTT), login, password. Every other field of the node — client id, keepalive, birth/close/will
|
||||
// messages — is left as it is. Node-RED never hands a stored password back, so the step keeps a
|
||||
// digest of what it last wrote: equal host/port/login and an equal digest is "already as the mesh
|
||||
// says".
|
||||
//
|
||||
// **Nothing loses its connection without someone seeing it.** The broker is asked first whether it
|
||||
// takes the delivered login; if not, nothing is written and the step fails saying why.
|
||||
//
|
||||
// Pure logic over two seams (Node-RED, the broker), tested against fakes (test/mqtt.test.ts).
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { Probe } from "./probe.js";
|
||||
|
||||
export const PROVISION = "mqtt-topic";
|
||||
/** The id and name of the broker node the step makes when none is named. */
|
||||
export const MESH_BROKER_ID = "mesh-mqtt-topic";
|
||||
export const MESH_BROKER_NAME = "mesh: mqtt-topic";
|
||||
|
||||
/** What the mesh wrote at `binds.mqtt-topic`. */
|
||||
export interface Binding {
|
||||
provision?: string;
|
||||
from?: string;
|
||||
at?: string;
|
||||
as?: string;
|
||||
serves?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type Outcome =
|
||||
| { what: string; result: "unchanged"; note?: string }
|
||||
| { what: string; result: "written"; fields: string[]; note?: string }
|
||||
| { what: string; result: "refused"; problem: string };
|
||||
|
||||
/** A flow node; a broker config node carries `broker`, `port`, `usetls`. */
|
||||
export interface FlowNode {
|
||||
id: string;
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Node-RED's admin API, as the step uses it. */
|
||||
export interface NodeRed {
|
||||
/** The whole flow configuration and its revision (API v2). */
|
||||
flows(): Promise<{ rev: string; flows: FlowNode[] }>;
|
||||
/** A node's stored credentials as Node-RED shows them: the user, and only whether a password is set. */
|
||||
credentials(type: string, id: string): Promise<{ user?: string; has_password?: boolean }>;
|
||||
/** Deploy the configuration against the revision it was read at; "nodes" restarts only what changed. */
|
||||
deploy(rev: string, flows: FlowNode[]): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Marks {
|
||||
get(name: string): Promise<string | undefined>;
|
||||
set(name: string, digest: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Deps {
|
||||
nodered: NodeRed;
|
||||
probe: Probe;
|
||||
marks: Marks;
|
||||
}
|
||||
|
||||
export interface Wanted {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function digest(...parts: (string | number)[]): string {
|
||||
return createHash("sha256").update(parts.map(String).join("\u0000")).digest("hex");
|
||||
}
|
||||
|
||||
function isLoopback(host: string): boolean {
|
||||
const h = host.toLowerCase();
|
||||
return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
|
||||
}
|
||||
|
||||
/**
|
||||
* The broker and login the mesh says Node-RED uses. A loopback `at` — what the mesh hands a machine
|
||||
* that is not on the private network — is refused: from Node-RED's own container it is Node-RED.
|
||||
*/
|
||||
export function wanted(binding: Binding | undefined, credential: string | undefined): { ok: true; want: Wanted } | { ok: false; problem: string } {
|
||||
if (!binding) return { ok: false, problem: `no binding for ${PROVISION} was delivered — the mesh writes it before this step runs` };
|
||||
const host = typeof binding.at === "string" ? binding.at.trim() : "";
|
||||
if (!host) return { ok: false, problem: `the ${PROVISION} binding names no host (at)` };
|
||||
if (isLoopback(host)) {
|
||||
return {
|
||||
ok: false,
|
||||
problem:
|
||||
`the ${PROVISION} binding says the broker is at ${host}, which from Node-RED's own container is Node-RED ` +
|
||||
`itself; put the machine on the private network so the broker has an address Node-RED can dial`,
|
||||
};
|
||||
}
|
||||
const port = Number(binding.serves?.port);
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
return { ok: false, problem: `the ${PROVISION} binding serves no usable port (${String(binding.serves?.port)})` };
|
||||
}
|
||||
const scheme = binding.serves?.scheme;
|
||||
if (scheme !== undefined && scheme !== "mqtt") return { ok: false, problem: `the ${PROVISION} binding serves scheme ${String(scheme)}; this step writes plain MQTT` };
|
||||
const user = typeof binding.as === "string" ? binding.as.trim() : "";
|
||||
if (!user) return { ok: false, problem: `the ${PROVISION} binding names no login (as)` };
|
||||
const password = (credential ?? "").replace(/\n$/, "");
|
||||
if (!password) return { ok: false, problem: `the ${PROVISION} credential is empty or was not delivered` };
|
||||
return { ok: true, want: { host, port, user, password } };
|
||||
}
|
||||
|
||||
/** The broker node ids an assignment named in settings (`mqtt.brokers`), or none. */
|
||||
export function namedBrokers(settings: unknown): string[] {
|
||||
const brokers = (settings as { mqtt?: { brokers?: unknown } } | undefined)?.mqtt?.brokers;
|
||||
return Array.isArray(brokers) ? brokers.filter((b): b is string => typeof b === "string" && b.length > 0) : [];
|
||||
}
|
||||
|
||||
/** A new broker node, Node-RED 5's defaults, pointed at the broker. */
|
||||
export function newBrokerNode(want: Wanted): FlowNode {
|
||||
return {
|
||||
id: MESH_BROKER_ID, type: "mqtt-broker", name: MESH_BROKER_NAME,
|
||||
broker: want.host, port: String(want.port), clientid: "", autoConnect: true, usetls: false,
|
||||
protocolVersion: "4", keepalive: "60", cleansession: true, autoUnsubscribe: true,
|
||||
birthTopic: "", birthQos: "0", birthRetain: "false", birthPayload: "", birthMsg: {},
|
||||
closeTopic: "", closeQos: "0", closeRetain: "false", closePayload: "", closeMsg: {},
|
||||
willTopic: "", willQos: "0", willRetain: "false", willPayload: "", willMsg: {},
|
||||
userProps: "", sessionExpiry: "",
|
||||
};
|
||||
}
|
||||
|
||||
const markFor = (id: string, w: Wanted): string => digest("nodered-mqtt", id, w.host, w.port, w.user, w.password);
|
||||
|
||||
function scrub(err: unknown, secret: string): string {
|
||||
let text = err instanceof Error ? err.message : String(err);
|
||||
for (const form of new Set([secret, encodeURIComponent(secret)])) text = text.split(form).join("***");
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the mesh's broker nodes in line with the binding: one outcome per node. Never throws. A node
|
||||
* the settings name that is not in the flows is refused (the others are still put right).
|
||||
*/
|
||||
export async function reconcileBrokers(deps: Deps, binding: Binding | undefined, credential: string | undefined, named: readonly string[]): Promise<Outcome[]> {
|
||||
const w = wanted(binding, credential);
|
||||
if ("problem" in w) return [{ what: "mqtt", result: "refused", problem: w.problem }];
|
||||
const want = w.want;
|
||||
|
||||
let note: string | undefined;
|
||||
try {
|
||||
const probe = await deps.probe(want.host, want.port, want.user, want.password, "#");
|
||||
if (probe.connack === 4 || probe.connack === 5) {
|
||||
return [{
|
||||
what: "mqtt",
|
||||
result: "refused",
|
||||
problem:
|
||||
`the broker at ${want.host}:${want.port} does not (yet) take the login ${want.user} with the delivered password ` +
|
||||
`(CONNACK ${probe.connack}); mosquitto's provisioner creates it from the grant — nothing was written`,
|
||||
}];
|
||||
}
|
||||
if (probe.connack !== 0) return [{ what: "mqtt", result: "refused", problem: `the broker at ${want.host}:${want.port} answered CONNACK ${probe.connack}; nothing was written` }];
|
||||
if (probe.suback === 0x80) note = `warning: ${want.user} may not subscribe to every topic; flows subscribing outside its grant will get nothing`;
|
||||
} catch (err) {
|
||||
return [{ what: "mqtt", result: "refused", problem: `the broker at ${want.host}:${want.port} could not be asked: ${scrub(err, want.password)}; nothing was written` }];
|
||||
}
|
||||
|
||||
const outcomes: Outcome[] = [];
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
outcomes.length = 0;
|
||||
try {
|
||||
const { rev, flows } = await deps.nodered.flows();
|
||||
const targets = named.length > 0 ? [...named] : [MESH_BROKER_ID];
|
||||
const changed: { id: string; fields: string[] }[] = [];
|
||||
for (const id of targets) {
|
||||
let node = flows.find((n) => n.id === id);
|
||||
if (node && node.type !== "mqtt-broker") {
|
||||
outcomes.push({ what: `broker ${id}`, result: "refused", problem: `node ${id} is a ${node.type}, not an mqtt-broker` });
|
||||
continue;
|
||||
}
|
||||
if (!node) {
|
||||
if (id !== MESH_BROKER_ID) {
|
||||
outcomes.push({ what: `broker ${id}`, result: "refused", problem: `the settings name broker node ${id}, and Node-RED's flows have no such node` });
|
||||
continue;
|
||||
}
|
||||
node = newBrokerNode(want);
|
||||
flows.push(node);
|
||||
node.credentials = { user: want.user, password: want.password };
|
||||
changed.push({ id, fields: ["node"] });
|
||||
continue;
|
||||
}
|
||||
const fields: string[] = [];
|
||||
if (String(node.broker ?? "") !== want.host) fields.push("broker");
|
||||
if (Number(node.port ?? 0) !== want.port) fields.push("port");
|
||||
if (node.usetls === true) fields.push("usetls");
|
||||
const creds = await deps.nodered.credentials("mqtt-broker", id);
|
||||
if ((creds.user ?? "") !== want.user) fields.push("user");
|
||||
if (!creds.has_password || (await deps.marks.get(`broker-${id}`)) !== markFor(id, want)) fields.push("password");
|
||||
if (fields.length === 0) {
|
||||
outcomes.push(note ? { what: `broker ${id}`, result: "unchanged", note } : { what: `broker ${id}`, result: "unchanged" });
|
||||
continue;
|
||||
}
|
||||
node.broker = want.host;
|
||||
node.port = String(want.port);
|
||||
node.usetls = false;
|
||||
node.credentials = { user: want.user, password: want.password };
|
||||
changed.push({ id, fields });
|
||||
}
|
||||
if (changed.length > 0) {
|
||||
await deps.nodered.deploy(rev, flows);
|
||||
for (const c of changed) {
|
||||
await deps.marks.set(`broker-${c.id}`, markFor(c.id, want));
|
||||
outcomes.push({ what: `broker ${c.id}`, result: "written", fields: c.fields, ...(note ? { note } : {}) });
|
||||
}
|
||||
}
|
||||
return outcomes;
|
||||
} catch (err) {
|
||||
// A deploy against a revision someone else changed meanwhile (409) is read again once.
|
||||
if (attempt === 0 && /\b409\b/.test(String(err))) continue;
|
||||
return [...outcomes, { what: "mqtt", result: "refused", problem: scrub(err, want.password) }];
|
||||
}
|
||||
}
|
||||
return outcomes;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// nodered's MQTT step — run once by the host after Node-RED starts, and again whenever the
|
||||
// `mqtt-topic` binding, its pair credential or the settings change (the container's `restart-on`,
|
||||
// novox/hq ADR 0099). It points the mesh's broker config nodes at the broker the mesh bound, through
|
||||
// Node-RED's admin API (connection.ts). It connects to no mesh broker.
|
||||
//
|
||||
// Exits non-zero when anything could not be put right, so the node reports the step failed and the
|
||||
// host runs it again on the next apply. Declared last in the manifest, so its failing gates nothing
|
||||
// else of nodered's (novox/hq ADR 0136). Never prints a password.
|
||||
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { NodeRedClient } from "../client.js";
|
||||
import { namedBrokers, reconcileBrokers, type Binding, type Marks } from "./connection.js";
|
||||
import { probeBroker } from "./probe.js";
|
||||
|
||||
const dir = process.env.MESH_PROVISIONS_DIR ?? "/run/provisions";
|
||||
const writtenDir = process.env.MESH_WRITTEN_DIR ?? "/var/lib/nodered-provisions";
|
||||
const waitSeconds = Number(process.env.MESH_NODERED_WAIT_SECONDS ?? "180");
|
||||
|
||||
const readIfThere = (path: string): Promise<string | undefined> => readFile(path, "utf8").catch(() => undefined);
|
||||
const parse = <T>(raw: string | undefined): T | undefined => {
|
||||
if (raw === undefined) return undefined;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const marks: Marks = {
|
||||
async get(name) {
|
||||
return (await readIfThere(join(writtenDir, `${name}.digest`)))?.trim() || undefined;
|
||||
},
|
||||
async set(name, value) {
|
||||
await mkdir(writtenDir, { recursive: true, mode: 0o700 });
|
||||
const path = join(writtenDir, `${name}.digest`);
|
||||
await writeFile(`${path}.tmp`, `${value}\n`, { mode: 0o600 });
|
||||
await rename(`${path}.tmp`, path);
|
||||
},
|
||||
};
|
||||
|
||||
let client: NodeRedClient;
|
||||
try {
|
||||
client = NodeRedClient.fromEnv();
|
||||
} catch (err) {
|
||||
console.error(`[nodered-mqtt] ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Node-RED answers the admin API once its flows are loaded and the token is good. */
|
||||
async function ready(): Promise<boolean> {
|
||||
const until = Date.now() + waitSeconds * 1000;
|
||||
for (;;) {
|
||||
try {
|
||||
await client.flowsWithRev();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (/\b(401|403)\b/.test(String(err))) {
|
||||
console.error("[nodered-mqtt] Node-RED refuses the api-token — settings.js and this step disagree");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (Date.now() >= until) return false;
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await ready())) {
|
||||
console.error(`[nodered-mqtt] Node-RED's admin API did not answer at ${client.baseUrl} within ${waitSeconds}s`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const binding = parse<Binding>(await readIfThere(join(dir, "mqtt-topic.json")));
|
||||
const secret = await readIfThere(join(dir, "mqtt-topic.secret"));
|
||||
const settings = parse<unknown>(await readIfThere(join(dir, "settings.json")));
|
||||
|
||||
const outcomes = await reconcileBrokers(
|
||||
{
|
||||
nodered: {
|
||||
flows: () => client.flowsWithRev(),
|
||||
credentials: (type, id) => client.credentials(type, id),
|
||||
deploy: (rev, flows) => client.deployFlowsAt(rev, flows, "nodes"),
|
||||
},
|
||||
probe: probeBroker,
|
||||
marks,
|
||||
},
|
||||
binding,
|
||||
secret,
|
||||
namedBrokers(settings),
|
||||
);
|
||||
|
||||
let failed = 0;
|
||||
for (const o of outcomes) {
|
||||
if (o.result === "unchanged") console.log(`[nodered-mqtt] ${o.what}: already as the mesh says${o.note ? ` — ${o.note}` : ""}`);
|
||||
else if (o.result === "written") console.log(`[nodered-mqtt] ${o.what}: wrote ${o.fields.join(", ")}${o.note ? ` — ${o.note}` : ""}`);
|
||||
else {
|
||||
failed++;
|
||||
console.error(`[nodered-mqtt] ${o.what}: ${o.problem}`);
|
||||
}
|
||||
}
|
||||
process.exitCode = failed > 0 ? 1 : 0;
|
||||
@@ -0,0 +1,117 @@
|
||||
// Ask the broker, before Node-RED is told anything, whether it takes the login and password the
|
||||
// mesh delivered — and whether that login may subscribe to every topic, as flows expect.
|
||||
//
|
||||
// One MQTT 3.1.1 session: CONNECT (clean, a throwaway client id, so no flow's session is taken
|
||||
// over), read the CONNACK, optionally SUBSCRIBE once and read the SUBACK, DISCONNECT. No dependency:
|
||||
// the handful of bytes MQTT needs for this are written here.
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { connect } from "node:net";
|
||||
|
||||
export interface ProbeResult {
|
||||
/** 0 accepted; 4 bad username or password; 5 not authorised. */
|
||||
connack: number;
|
||||
/** The SUBACK return code for the filter asked about: 0–2 granted, 0x80 refused. */
|
||||
suback?: number;
|
||||
}
|
||||
|
||||
export type Probe = (host: string, port: number, username: string, password: string, subscribe?: string) => Promise<ProbeResult>;
|
||||
|
||||
function str(v: string): Buffer {
|
||||
const b = Buffer.from(v, "utf8");
|
||||
const len = Buffer.alloc(2);
|
||||
len.writeUInt16BE(b.length);
|
||||
return Buffer.concat([len, b]);
|
||||
}
|
||||
|
||||
function packet(type: number, body: Buffer): Buffer {
|
||||
let remaining = body.length;
|
||||
const lenBytes: number[] = [];
|
||||
do {
|
||||
let byte = remaining % 128;
|
||||
remaining = Math.floor(remaining / 128);
|
||||
if (remaining > 0) byte |= 0x80;
|
||||
lenBytes.push(byte);
|
||||
} while (remaining > 0);
|
||||
return Buffer.concat([Buffer.from([type, ...lenBytes]), body]);
|
||||
}
|
||||
|
||||
/** The first complete packet in `buf`: its type byte, its body, and how many bytes it took. */
|
||||
export function firstPacket(buf: Buffer): { type: number; body: Buffer; used: number } | undefined {
|
||||
if (buf.length < 2) return undefined;
|
||||
let length = 0;
|
||||
let multiplier = 1;
|
||||
let i = 1;
|
||||
for (;;) {
|
||||
if (i >= buf.length) return undefined;
|
||||
const byte = buf[i++];
|
||||
length += (byte & 0x7f) * multiplier;
|
||||
if ((byte & 0x80) === 0) break;
|
||||
multiplier *= 128;
|
||||
if (i > 4) throw new Error("malformed MQTT remaining length");
|
||||
}
|
||||
if (buf.length < i + length) return undefined;
|
||||
return { type: buf[0], body: buf.subarray(i, i + length), used: i + length };
|
||||
}
|
||||
|
||||
export const probeBroker: Probe = (host, port, username, password, subscribe) => {
|
||||
const connectBody = Buffer.concat([
|
||||
str("MQTT"),
|
||||
Buffer.from([4, 0xc2, 0, 10]), // level 4 (3.1.1); username + password + clean session; keepalive 10s
|
||||
str(`mesh-probe-${randomBytes(6).toString("hex")}`),
|
||||
str(username),
|
||||
str(password),
|
||||
]);
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect({ host, port });
|
||||
let buf = Buffer.alloc(0);
|
||||
const result: ProbeResult = { connack: -1 };
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error(`no answer from the broker at ${host}:${port} within 10s`));
|
||||
}, 10_000);
|
||||
const finish = (): void => {
|
||||
clearTimeout(timer);
|
||||
if (result.connack === 0) socket.end(Buffer.from([0xe0, 0]));
|
||||
else socket.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
socket.on("connect", () => socket.write(packet(0x10, connectBody)));
|
||||
socket.on("data", (chunk) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
for (;;) {
|
||||
let p;
|
||||
try {
|
||||
p = firstPacket(buf);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (!p) return;
|
||||
buf = buf.subarray(p.used);
|
||||
const kind = p.type >> 4;
|
||||
if (kind === 2) {
|
||||
result.connack = p.body[1] ?? -1;
|
||||
if (result.connack !== 0 || !subscribe) return finish();
|
||||
// SUBSCRIBE, packet id 1, one filter at QoS 0.
|
||||
socket.write(packet(0x82, Buffer.concat([Buffer.from([0, 1]), str(subscribe), Buffer.from([0])])));
|
||||
} else if (kind === 9) {
|
||||
result.suback = p.body[2];
|
||||
return finish();
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
socket.on("close", () => {
|
||||
if (result.connack === -1) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`the broker at ${host}:${port} closed the connection without answering`));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user