{ config, lib, pkgs, ... }: let cfg = config.services.gebos.hivemq; # HiveMQ CE — fetched from the upstream GitHub release and unpacked into the # Nix store. The store tree is read-only; the runtime data/log/extension # folders are redirected to the writable state dir (see ExecStart below). hivemq-ce = pkgs.stdenvNoCC.mkDerivation rec { pname = "hivemq-ce"; version = "2026.5"; src = pkgs.fetchurl { url = "https://github.com/hivemq/hivemq-community-edition/releases/download/${version}/hivemq-ce-${version}.zip"; hash = "sha256-/QBeU17KC6/7CF4RsVEohdBs4AeG+WFwHHZEmBb33sU="; }; nativeBuildInputs = [ pkgs.unzip ]; dontConfigure = true; dontBuild = true; installPhase = '' runHook preInstall mkdir -p "$out" cp -r . "$out/" runHook postInstall ''; meta = with lib; { description = "HiveMQ Community Edition — open-source MQTT 3.x / 5 broker"; homepage = "https://github.com/hivemq/hivemq-community-edition"; license = licenses.asl20; platforms = platforms.linux; }; }; # HiveMQ file-RBAC extension — adds username/password authentication and # topic-level authorization, replacing the insecure hivemq-allow-all that # ships in the broker package. The zip unpacks to a single top-level # `hivemq-file-rbac-extension/` folder which we keep verbatim in $out. hivemq-file-rbac = pkgs.stdenvNoCC.mkDerivation rec { pname = "hivemq-file-rbac-extension"; version = "4.6.15"; src = pkgs.fetchurl { url = "https://github.com/hivemq/hivemq-file-rbac-extension/releases/download/${version}/hivemq-file-rbac-extension-${version}.zip"; hash = "sha256-R6PM+qAvEKMHdX3FTR0CfR8yarJrc2WeFAEu4eXH3Ps="; }; nativeBuildInputs = [ pkgs.unzip ]; dontConfigure = true; dontBuild = true; # stdenv unpacks the single top-level archive dir and cds into it, so $PWD # already holds the extension's files (jar, conf/, hivemq-extension.xml). installPhase = '' runHook preInstall mkdir -p "$out/hivemq-file-rbac-extension" cp -r . "$out/hivemq-file-rbac-extension/" runHook postInstall ''; meta = with lib; { description = "HiveMQ file-based role-based access control extension"; homepage = "https://github.com/hivemq/hivemq-file-rbac-extension"; license = licenses.asl20; platforms = platforms.linux; }; }; stateDir = "/var/lib/gebos-hivemq"; # Non-secret RBAC extension config (reload interval + password format). The # users/roles live in the secret credentials.xml, symlinked in at runtime. rbacConfigXml = pkgs.writeText "hivemq-file-rbac-config.xml" '' ${toString cfg.rbac.reloadInterval} ${cfg.rbac.passwordType} ''; # config.xml rendered from the module options. The local TCP listener is what # the ingester (and, eventually, the Caddy TLS frontend) connects to. configXml = pkgs.writeText "hivemq-config.xml" '' ${toString cfg.port} ${cfg.bindAddress} ${if cfg.persistentSessions then "file" else "in-memory"} false ''; # Read-only conf folder: our generated config.xml plus the upstream # logback.xml / config.xsd so HiveMQ logs and validates as shipped. confDir = pkgs.runCommand "hivemq-conf" { } '' mkdir -p "$out" cp ${configXml} "$out/config.xml" cp ${hivemq-ce}/conf/logback.xml "$out/logback.xml" cp ${hivemq-ce}/conf/config.xsd "$out/config.xsd" ''; # run.sh's JVM flags, reproduced so we can invoke java directly and point # HiveMQ at the writable state dir via -Dhivemq.*.folder. javaOpts = lib.concatStringsSep " " [ "-Djava.net.preferIPv4Stack=true" "--add-opens java.base/java.lang=ALL-UNNAMED" "--add-opens java.base/java.nio=ALL-UNNAMED" "--add-opens java.base/sun.nio.ch=ALL-UNNAMED" "--add-opens jdk.management/com.sun.management.internal=ALL-UNNAMED" "--add-exports java.base/jdk.internal.misc=ALL-UNNAMED" "-Djava.security.egd=file:/dev/./urandom" "-Duser.language=en -Duser.region=US" "-XX:+CrashOnOutOfMemoryError" "-XX:+HeapDumpOnOutOfMemoryError" "-XX:HeapDumpPath=${stateDir}/log/heap-dump.hprof" "-XX:ErrorFile=${stateDir}/log/hs_err_pid%p.log" ]; in { options.services.gebos.hivemq = { enable = lib.mkEnableOption "HiveMQ CE single-node broker"; package = lib.mkOption { type = lib.types.package; default = hivemq-ce; defaultText = lib.literalExpression "pkgs.hivemq-ce (vendored)"; description = "HiveMQ CE package to run."; }; port = lib.mkOption { type = lib.types.port; default = 1883; description = "Plain MQTT TCP listener port (what the ingester connects to)."; }; bindAddress = lib.mkOption { type = lib.types.str; default = "127.0.0.1"; description = '' Address the MQTT listener binds to. Defaults to loopback: external clients reach the broker through the Caddy TLS frontend, not directly. ''; }; persistentSessions = lib.mkOption { type = lib.types.bool; default = true; description = '' Whether client sessions and queued messages survive a restart (file persistence) or are kept only in memory. ''; }; tlsPort = lib.mkOption { type = lib.types.port; default = 8883; description = '' Externally advertised MQTTS port. TLS is currently terminated by the Caddy frontend (see gebos-caddy), not by HiveMQ directly, so this value is informational until a keystore is wired into the broker. ''; }; rbac = { package = lib.mkOption { type = lib.types.package; default = hivemq-file-rbac; defaultText = lib.literalExpression "pkgs.hivemq-file-rbac (vendored)"; description = "HiveMQ file-RBAC authn/authz extension package."; }; passwordType = lib.mkOption { type = lib.types.enum [ "HASHED" "PLAIN" ]; default = "PLAIN"; description = '' Whether passwords in the credentials file are PBKDF2 HASHED or PLAIN text. PLAIN is acceptable here because the credentials file is a sops-encrypted secret at rest and rendered 0400/gebos-hivemq into a tmpfs at runtime; switch to HASHED for defence-in-depth. ''; }; reloadInterval = lib.mkOption { type = lib.types.ints.positive; default = 60; description = "Seconds between credentials.xml reloads by the extension."; }; credentialsFile = lib.mkOption { type = lib.types.path; default = config.services.gebos.secrets.hivemqCredentialsFile; defaultText = lib.literalExpression "config.services.gebos.secrets.hivemqCredentialsFile"; description = '' Path to the file-RBAC credentials.xml (users, roles, topic permissions). Defaults to the sops-rendered secret; symlinked into the extension's conf/ dir so it never lands in the writable state dir. ''; }; }; }; config = lib.mkIf cfg.enable { users.users.gebos-hivemq = { isSystemUser = true; group = "gebos-hivemq"; description = "Gebos HiveMQ CE service user"; }; users.groups.gebos-hivemq = { }; systemd.services.gebos-hivemq = { description = "HiveMQ CE"; after = [ "network.target" ]; wantedBy = [ "multi-user.target" ]; # HiveMQ wants the extensions folder writable (it manages enable/disable # markers there), so the extension lives under the state dir. We reconcile # it on every start: (re)install the vendored file-RBAC extension when its # store path changes, refresh its non-secret config, link the secret # credentials from the sops tmpfs path, and remove the insecure allow-all # extension that earlier deploys seeded from the broker package. preStart = '' set -eu mkdir -p ${stateDir}/{data,log,extensions} extDir=${stateDir}/extensions/hivemq-file-rbac-extension marker=${stateDir}/extensions/.rbac-version if [ "$(cat "$marker" 2>/dev/null || true)" != "${cfg.rbac.package}" ]; then rm -rf "$extDir" cp -r --no-preserve=mode ${cfg.rbac.package}/hivemq-file-rbac-extension "$extDir" echo "${cfg.rbac.package}" > "$marker" fi cp --no-preserve=mode ${rbacConfigXml} "$extDir/conf/config.xml" ln -sf ${cfg.rbac.credentialsFile} "$extDir/conf/credentials.xml" rm -rf ${stateDir}/extensions/hivemq-allow-all-extension ''; serviceConfig = { Type = "simple"; User = "gebos-hivemq"; Group = "gebos-hivemq"; StateDirectory = "gebos-hivemq"; WorkingDirectory = stateDir; ExecStart = '' ${pkgs.jre_headless}/bin/java \ -Dhivemq.home=${cfg.package} \ -Dhivemq.config.folder=${confDir} \ -Dhivemq.log.folder=${stateDir}/log \ -Dhivemq.data.folder=${stateDir}/data \ -Dhivemq.extensions.folder=${stateDir}/extensions \ ${javaOpts} \ -jar ${cfg.package}/bin/hivemq.jar ''; Restart = "on-failure"; RestartSec = "5s"; # HiveMQ exits 143 on SIGTERM shutdown — treat that as success. SuccessExitStatus = "143"; KillSignal = "SIGTERM"; LimitNOFILE = 65536; }; }; }; }