mosquitto: seed dynsec with a run-once bootstrap before the broker

The Dynamic Security plugin refuses to start the broker unless
dynamic-security.json already holds an admin client, and no reconcile loop
seeds it (novox/hq ADR 0052). Add a run-once init container, declared before
the server container, that runs mosquitto's own bootstrap entrypoint in the
runtime image: it seeds the store offline via mosquitto_ctrl and exits, and
the host gates the broker on its completion.

The bootstrap hands the seeded file to the broker's user (uid 1883, chown +
0600): the broker must read the seed at startup AND persist to it as clients
come and go, but the init container runs as root and would otherwise leave a
file the broker can neither read nor rewrite. This is the ownership question
ADR 0052 left for the lab to settle. It seeds only when the file is absent, so
what the running plugin grows is never clobbered (issue 035).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-06 00:33:28 +02:00
parent eb5cf65be3
commit ec1e7189a3
3 changed files with 69 additions and 1 deletions
+47
View File
@@ -0,0 +1,47 @@
// mosquitto's run-once bootstrap — mosquitto's own code (novox/hq ADR 0039), run once before the
// broker first starts (ADR 0052). The Dynamic Security plugin refuses to bring the broker up unless
// `dynamic-security.json` already holds an admin client, and nothing in a reconcile loop ever seeds
// that file — a state declaration describes what should exist, not a step that runs. This is that
// step: it writes the seed offline, exactly once, and exits. The host runs it to completion and only
// then starts the broker container the manifest places after it.
//
// It runs in the module's own runtime image, under the module's own account, as `mesh-tools run`
// imports it — no broker connection, because seeding is an offline file operation and there is no
// broker to reach yet. `mosquitto_ctrl dynsec init` writes the file; nothing here talks to a server.
//
// **Two disciplines make the seed safe to live beside a file the plugin then grows:**
//
// - It writes only when the file is absent, and never reconciles it. Once the broker is up, the
// dynsec plugin owns that file and rewrites it on every client it creates; re-seeding would wipe
// every provisioned client (novox/hq 04-ISSUES/035). The host's completion marker keeps the step
// from re-running; this absent-check keeps the one pass it does run from clobbering.
// - It hands the file to the broker's user. The broker runs as uid 1883 and must both READ the
// seed at startup and PERSIST to it as clients come and go; this container runs as root and would
// otherwise leave a root-owned file the broker at 1883 can neither read (if 0600) nor rewrite.
// So after writing, it chowns the file to 1883:1883 and sets 0600 — the broker's to read and to
// grow, and no one else's. This is the ownership question ADR 0052 left for the lab to settle.
import { chownSync, chmodSync, existsSync } from "node:fs";
import { MosquittoClient } from "../client.js";
// The broker (eclipse-mosquitto) runs as this uid/gid; the seeded store must be its to read and
// rewrite. Overridable for a broker image that runs as a different user.
const BROKER_UID = Number(process.env.MESH_MQTT_BROKER_UID ?? "1883") || 1883;
const BROKER_GID = Number(process.env.MESH_MQTT_BROKER_GID ?? "1883") || 1883;
// The same path the broker's `plugin_opt_config_file` names, reached through the shared data volume.
const configFile = process.env.MESH_DYNSEC_FILE ?? "/mosquitto/data/dynamic-security.json";
if (existsSync(configFile)) {
// Already seeded — and possibly grown by the running plugin since. Leave it exactly as it is.
console.log(`[mosquitto:bootstrap] ${configFile} already exists; leaving it untouched`);
} else {
const mosquitto = MosquittoClient.fromEnv();
await mosquitto.initBootstrapFile(configFile);
// Hand the store to the broker's user so it can read the seed and persist to it (see the header).
chownSync(configFile, BROKER_UID, BROKER_GID);
chmodSync(configFile, 0o600);
console.log(
`[mosquitto:bootstrap] seeded ${configFile} with the dynsec admin client, owned by ${BROKER_UID}:${BROKER_GID}`,
);
}
+21
View File
@@ -84,6 +84,27 @@
"type": "network",
"name": "mosquitto"
},
{
"id": "bootstrap",
"type": "container",
"name": "mosquitto-bootstrap",
"image": "mesh-runtime-mosquitto@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"run-once": true,
"volumes": [
"/services/mosquitto/data:/mosquitto/data",
"/var/lib/mosquitto-module/admin.secret:/run/secrets/admin:ro"
],
"env": {
"MESH_PROVISION_MQTT": "mosquitto:1883",
"MESH_PROVISION_ADMIN_USER": "mesh-admin",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/admin",
"MESH_DYNSEC_FILE": "/mosquitto/data/dynamic-security.json"
},
"args": [
"run",
"/app/modules/mosquitto/dist/bootstrap/index.js"
]
},
{
"id": "server",
"type": "container",
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts", "bootstrap/index.ts"]
}