diff --git a/modules/mosquitto/bootstrap/index.ts b/modules/mosquitto/bootstrap/index.ts new file mode 100644 index 0000000..53864fd --- /dev/null +++ b/modules/mosquitto/bootstrap/index.ts @@ -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}`, + ); +} diff --git a/modules/mosquitto/module.json b/modules/mosquitto/module.json index b804b82..1d730cf 100644 --- a/modules/mosquitto/module.json +++ b/modules/mosquitto/module.json @@ -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", diff --git a/modules/mosquitto/tsconfig.json b/modules/mosquitto/tsconfig.json index 51f4046..95f347f 100644 --- a/modules/mosquitto/tsconfig.json +++ b/modules/mosquitto/tsconfig.json @@ -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"] }