// 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. No secret is ever on its command line (novox/hq issue 282). import assert from "node:assert/strict"; import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { MosquittoClient, OPTIONS_SHELL, passwordOnArgv } from "../dist/client.js"; // compiled: client.ts uses parameter properties, which type stripping cannot run // Made up for the test. const ADMIN = "admin-pw-for-the-test-0123"; const CONSUMER = "consumer-pw-for-the-test-4567"; const env = { MESH_PROVISION_MQTT: "127.0.0.1:21883", MESH_MQTT_PASSWORD: ADMIN }; test("named, the broker's container runs mosquitto_ctrl under the options shell, stdin open", () => { const c = MosquittoClient.fromEnv({ ...env, MESH_MQTT_CTRL_CONTAINER: "mosquitto" }); assert.deepEqual(c.ctrl(["dynsec", "listClients"]), ["docker", ["exec", "-i", "mosquitto", "sh", "-c", OPTIONS_SHELL, "mosquitto_ctrl", "dynsec", "listClients"]]); }); test("unnamed, this machine's mosquitto_ctrl runs under the same shell", () => { const c = MosquittoClient.fromEnv(env); assert.deepEqual(c.ctrl(["dynsec", "listClients"]), ["sh", ["-c", OPTIONS_SHELL, "mosquitto_ctrl", "dynsec", "listClients"]]); }); test("the options shell itself holds no secret", () => { assert.ok(!OPTIONS_SHELL.includes(ADMIN)); assert.match(OPTIONS_SHELL, /mosquitto_ctrl -o "\$f" "\$@"/); }); test("an argv carrying the admin password or a password being set is refused, by name", () => { const c = MosquittoClient.fromEnv(env); assert.throws(() => c.assertNoSecret(["mosquitto_ctrl", "-P", ADMIN]), (e: Error) => /admin password/.test(e.message) && !e.message.includes(ADMIN)); assert.throws(() => c.assertNoSecret(["mosquitto_ctrl", "createClient", "x", "-p", CONSUMER], [CONSUMER]), (e: Error) => /a client password/.test(e.message) && !e.message.includes(CONSUMER)); c.assertNoSecret(["mosquitto_ctrl", "-h", "127.0.0.1", "dynsec", "listClients"], [CONSUMER]); }); test("the admin tool refuses a dynsec command that would put a password on a command line", () => { assert.ok(passwordOnArgv(["createClient", "x", "-p", "pw"])); assert.ok(passwordOnArgv(["setClientPassword", "x", "pw"])); assert.ok(passwordOnArgv(["init", "/f", "admin", "pw"])); assert.equal(passwordOnArgv(["createClient", "x"]), undefined); assert.equal(passwordOnArgv(["getClient", "x"]), undefined); }); // A mosquitto_ctrl stand-in on PATH: it records its argv, the options file it was given and what is // left on stdin, so the whole hand-over is checked without a broker. function fakeCtrl(): { dir: string; seen: () => { argv: string; options: string; stdin: string } } { const dir = mkdtempSync(join(tmpdir(), "mosq-ctrl-")); const script = `#!/bin/sh printf '%s\\n' "$@" > "${dir}/argv" [ "$1" = "-o" ] && cat "$2" > "${dir}/options" && stat -c %a "$2" >> "${dir}/options" cat > "${dir}/stdin" echo ok `; writeFileSync(join(dir, "mosquitto_ctrl"), script); chmodSync(join(dir, "mosquitto_ctrl"), 0o755); return { dir, seen: () => ({ argv: readFileSync(join(dir, "argv"), "utf8"), options: readFileSync(join(dir, "options"), "utf8"), stdin: readFileSync(join(dir, "stdin"), "utf8"), }), }; } test("the admin's name and password reach mosquitto_ctrl in a 0600 options file, and a password being set at its prompt", async () => { const fake = fakeCtrl(); const path = process.env.PATH; process.env.PATH = `${fake.dir}:${path}`; try { const c = MosquittoClient.fromEnv(env); await c.ctlWithPassword(CONSUMER, "createClient", "alice"); const seen = fake.seen(); assert.ok(!seen.argv.includes(ADMIN) && !seen.argv.includes(CONSUMER), "a password reached argv"); assert.equal(seen.options, `-u mesh-admin\n-P ${ADMIN}\n600\n`); assert.equal(seen.stdin, `${CONSUMER}\n${CONSUMER}\n`); assert.match(seen.argv, /^-o\n.+\n-h\n127\.0\.0\.1\n-p\n21883\ndynsec\ncreateClient\nalice\n$/); } finally { process.env.PATH = path; } });