diff --git a/modules/mosquitto/client.ts b/modules/mosquitto/client.ts index 4658662..ae8ed2e 100644 --- a/modules/mosquitto/client.ts +++ b/modules/mosquitto/client.ts @@ -18,6 +18,7 @@ import { randomBytes } from "node:crypto"; import { connect as tcpConnect } from "node:net"; import { readFileSync } from "node:fs"; import { execFile } from "node:child_process"; +import { basename, dirname } from "node:path"; import { promisify } from "node:util"; import { missingAcls, parseRoleAcls, staleAcls, wantedAcls } from "./topics.js"; @@ -30,6 +31,14 @@ export interface MqttConn { /** The Dynamic Security admin client the runtime authenticates as. */ readonly adminUser: string; readonly adminPassword: string; + /** + * The broker's own container, when `mosquitto_ctrl` is run inside it rather than from this + * machine's packages. The broker's image carries the tool at the broker's version, and inside it + * the broker listens on 127.0.0.1:1883 whatever port the machine publishes. + */ + readonly container?: string; + /** The broker's image, to seed the security file before the broker has ever started. */ + readonly image?: string; } export class MosquittoClient { @@ -53,7 +62,11 @@ export class MosquittoClient { "mosquitto host or admin password is not set — mosquitto's own code cannot reach the broker", ); } - return new MosquittoClient({ host, port, adminUser, adminPassword: adminPassword ?? "" }); + return new MosquittoClient({ + host, port, adminUser, adminPassword: adminPassword ?? "", + container: env.MESH_MQTT_CTRL_CONTAINER || undefined, + image: env.MESH_MQTT_CTRL_IMAGE || undefined, + }); } get host(): string { @@ -84,16 +97,18 @@ export class MosquittoClient { * to this single-purpose runtime container; see the module README. */ async ctl(...args: string[]): Promise { + const inside = this.conn.container !== undefined; const base = [ - "-h", this.conn.host, - "-p", String(this.conn.port), + "-h", inside ? "127.0.0.1" : this.conn.host, + "-p", inside ? "1883" : String(this.conn.port), "-u", this.conn.adminUser, "-P", this.conn.adminPassword, ]; let stdout: string; let stderr: string; try { - ({ stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], { + const [command, argv] = this.ctrl([...base, "dynsec", ...args]); + ({ stdout, stderr } = await run(command, argv, { maxBuffer: 16 << 20, timeout: 30_000, })); @@ -240,10 +255,27 @@ export class MosquittoClient { async initBootstrapFile(configFile: string): Promise { // `dynsec init [admin-password]` is an offline file operation — it does // not connect to the broker. The password is a positional argument (omitting it prompts). + if (this.conn.image) { + // Before the broker has ever started there is no container to enter: a throwaway one from the + // broker's own image writes the file into the directory the broker will mount. + await run("docker", [ + "run", "--rm", "--entrypoint", "mosquitto_ctrl", + "-v", `${dirname(configFile)}:/mosquitto/data`, + this.conn.image, + "dynsec", "init", `/mosquitto/data/${basename(configFile)}`, this.conn.adminUser, this.conn.adminPassword, + ], { maxBuffer: 16 << 20 }); + return; + } await run("mosquitto_ctrl", ["dynsec", "init", configFile, this.conn.adminUser, this.conn.adminPassword], { maxBuffer: 16 << 20, }); } + + /** How `mosquitto_ctrl` is run here: inside the broker's container when one is named. */ + ctrl(argv: string[]): [string, string[]] { + if (this.conn.container) return ["docker", ["exec", this.conn.container, "mosquitto_ctrl", ...argv]]; + return ["mosquitto_ctrl", argv]; + } } /** Generate a URL-safe password with no argv- or MQTT-hostile characters. */ diff --git a/modules/mosquitto/module.json b/modules/mosquitto/module.json index 88be665..9308199 100644 --- a/modules/mosquitto/module.json +++ b/modules/mosquitto/module.json @@ -92,7 +92,7 @@ "type": "file", "path": "${dir:state}/bootstrap.env", "mode": "0600", - "content": "MESH_PROVISION_MQTT=127.0.0.1:${port:1883}\nMESH_PROVISION_ADMIN_USER=mesh-admin\nMESH_PROVISION_PASSWORD_FILE=${dir:mesh-state}/admin\nMESH_DYNSEC_FILE=${dir:data}/dynamic-security.json\n" + "content": "MESH_PROVISION_MQTT=127.0.0.1:${port:1883}\nMESH_PROVISION_ADMIN_USER=mesh-admin\nMESH_PROVISION_PASSWORD_FILE=${dir:mesh-state}/admin\nMESH_DYNSEC_FILE=${dir:data}/dynamic-security.json\nMESH_MQTT_CTRL_IMAGE=eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408\n" }, { "id": "bootstrap", @@ -125,11 +125,6 @@ "${dir:data}:/mosquitto/data", "${dir:state}/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro" ] - }, - { - "id": "client", - "type": "package", - "package": "mosquitto" } ], "build": { @@ -153,7 +148,8 @@ "MESH_RECEIVES": "${dir:grants}/mesh.json", "MESH_PROVISION_MQTT": "127.0.0.1:${port:1883}", "MESH_PROVISION_ADMIN_USER": "mesh-admin", - "MESH_PROVISION_PASSWORD_FILE": "${dir:mesh-state}/admin" + "MESH_PROVISION_PASSWORD_FILE": "${dir:mesh-state}/admin", + "MESH_MQTT_CTRL_CONTAINER": "mosquitto" } } ] diff --git a/modules/mosquitto/test/ctrl.test.ts b/modules/mosquitto/test/ctrl.test.ts new file mode 100644 index 0000000..495c6b1 --- /dev/null +++ b/modules/mosquitto/test/ctrl.test.ts @@ -0,0 +1,19 @@ +// Run after `npm run build`. +// mosquitto_ctrl runs inside the broker's own container when the manifest names it, so a machine +// needs no mosquitto package (whose index may be too stale to install from) and the tool always +// matches the broker's version. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { MosquittoClient } from "../dist/client.js"; // compiled: client.ts uses parameter properties, which type stripping cannot run + +const env = { MESH_PROVISION_MQTT: "127.0.0.1:21883", MESH_MQTT_PASSWORD: "pw" }; + +test("named, the broker's container runs mosquitto_ctrl", () => { + const c = MosquittoClient.fromEnv({ ...env, MESH_MQTT_CTRL_CONTAINER: "mosquitto" }); + assert.deepEqual(c.ctrl(["dynsec", "listClients"]), ["docker", ["exec", "mosquitto", "mosquitto_ctrl", "dynsec", "listClients"]]); +}); + +test("unnamed, this machine's mosquitto_ctrl runs", () => { + const c = MosquittoClient.fromEnv(env); + assert.deepEqual(c.ctrl(["dynsec", "listClients"]), ["mosquitto_ctrl", ["dynsec", "listClients"]]); +});