Files
mesh-catalog/modules/mosquitto/test/ctrl.test.ts
T
jochen 13b7562c47
mesh/merge-gate pass: builds docker, keycloak, minio, mosquitto → ace, g14, novox, shanks; no bus step; 2 wait(s) for a person; every machine composes with…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Keep secrets off command lines the runtime records (hq issue 282)
mosquitto passed the broker's admin password to mosquitto_ctrl as -P on
every docker exec, and the container runtime keeps every exec's command
line in its event stream, where docker_events returned it. The admin
credentials now reach mosquitto_ctrl as a 0600 options file fed on
stdin, client passwords at its own prompt, and an argv carrying a secret
is refused before it runs. The admin secret says it is taken at start:
the bootstrap re-runs when the mesh replaces it and re-keys the broker
online from the value it last applied, so it can be rotated.

docker_events redacts what an exec's command line carried, and
docker_secrets_in_events names such secrets by name. keycloak's repair
hands kcadm its passwords through KC_CLI_PASSWORD; minio gives mc its
root alias through MC_HOST_mesh.
2026-10-07 01:37:21 +02:00

88 lines
4.2 KiB
TypeScript

// 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;
}
});