summary refs log tree commit diff
path: root/nixos/modules/services/networking/mosquitto.nix
diff options
context:
space:
mode:
authorpennae <github@quasiparticle.net>2021-05-21 19:23:01 +0200
committertomberek <tomberek@users.noreply.github.com>2021-10-22 16:06:55 -0400
commit56d0b5cd6a61da42cca5be52b216bad9d1ff2b59 (patch)
treed5130830244bc733d86dceb65517125c96f00551 /nixos/modules/services/networking/mosquitto.nix
parent4b91c770d05b1c9c165daad168f550e47347946d (diff)
downloadnixpkgs-56d0b5cd6a61da42cca5be52b216bad9d1ff2b59.tar
nixpkgs-56d0b5cd6a61da42cca5be52b216bad9d1ff2b59.tar.gz
nixpkgs-56d0b5cd6a61da42cca5be52b216bad9d1ff2b59.tar.bz2
nixpkgs-56d0b5cd6a61da42cca5be52b216bad9d1ff2b59.tar.lz
nixpkgs-56d0b5cd6a61da42cca5be52b216bad9d1ff2b59.tar.xz
nixpkgs-56d0b5cd6a61da42cca5be52b216bad9d1ff2b59.tar.zst
nixpkgs-56d0b5cd6a61da42cca5be52b216bad9d1ff2b59.zip
nixos/mosquitto: rewrite the module
mosquitto needs a lot of attention concerning its config because it doesn't
parse it very well, often ignoring trailing parts of lines, duplicated config
keys, or just looking back way further in the file to associated config keys
with previously defined items than might be expected.

this replaces the mosquitto module completely. we now have a hierarchical config
that flattens out to the mosquitto format (hopefully) without introducing spooky
action at a distance.
Diffstat (limited to 'nixos/modules/services/networking/mosquitto.nix')
-rw-r--r--nixos/modules/services/networking/mosquitto.nix690
1 files changed, 517 insertions, 173 deletions
diff --git a/nixos/modules/services/networking/mosquitto.nix b/nixos/modules/services/networking/mosquitto.nix
index b0fbfc19408..5a573cbf4ac 100644
--- a/nixos/modules/services/networking/mosquitto.nix
+++ b/nixos/modules/services/networking/mosquitto.nix
@@ -5,215 +5,543 @@ with lib;
 let
   cfg = config.services.mosquitto;
 
-  listenerConf = optionalString cfg.ssl.enable ''
-    listener ${toString cfg.ssl.port} ${cfg.ssl.host}
-    cafile ${cfg.ssl.cafile}
-    certfile ${cfg.ssl.certfile}
-    keyfile ${cfg.ssl.keyfile}
-  '';
-
-  passwordConf = optionalString cfg.checkPasswords ''
-    password_file ${cfg.dataDir}/passwd
-  '';
-
-  mosquittoConf = pkgs.writeText "mosquitto.conf" ''
-    acl_file ${aclFile}
-    persistence true
-    allow_anonymous ${boolToString cfg.allowAnonymous}
-    listener ${toString cfg.port} ${cfg.host}
-    ${passwordConf}
-    ${listenerConf}
-    ${cfg.extraConf}
-  '';
-
-  userAcl = (concatStringsSep "\n\n" (mapAttrsToList (n: c:
-    "user ${n}\n" + (concatStringsSep "\n" c.acl)) cfg.users
-  ));
-
-  aclFile = pkgs.writeText "mosquitto.acl" ''
-    ${cfg.aclExtraConf}
-    ${userAcl}
-  '';
-
-in
-
-{
+  # note that mosquitto config parsing is very simplistic as of may 2021.
+  # often times they'll e.g. strtok() a line, check the first two tokens, and ignore the rest.
+  # there's no escaping available either, so we have to prevent any being necessary.
+  str = types.strMatching "[^\r\n]*" // {
+    description = "single-line string";
+  };
+  path = types.addCheck types.path (p: str.check "${p}");
+  configKey = types.strMatching "[^\r\n\t ]+";
+  optionType = with types; oneOf [ str path bool int ] // {
+    description = "string, path, bool, or integer";
+  };
+  optionToString = v:
+    if isBool v then boolToString v
+    else if path.check v then "${v}"
+    else toString v;
+
+  assertKeysValid = prefix: valid: config:
+    mapAttrsToList
+      (n: _: {
+        assertion = valid ? ${n};
+        message = "Invalid config key ${prefix}.${n}.";
+      })
+      config;
+
+  formatFreeform = { prefix ? "" }: mapAttrsToList (n: v: "${prefix}${n} ${optionToString v}");
+
+  userOptions = with types; submodule {
+    options = {
+      password = mkOption {
+        type = uniq (nullOr str);
+        default = null;
+        description = ''
+          Specifies the (clear text) password for the MQTT User.
+        '';
+      };
 
-  ###### Interface
+      passwordFile = mkOption {
+        type = uniq (nullOr types.path);
+        example = "/path/to/file";
+        default = null;
+        description = ''
+          Specifies the path to a file containing the
+          clear text password for the MQTT user.
+        '';
+      };
 
-  options = {
-    services.mosquitto = {
-      enable = mkEnableOption "the MQTT Mosquitto broker";
+      hashedPassword = mkOption {
+        type = uniq (nullOr str);
+        default = null;
+        description = ''
+          Specifies the hashed password for the MQTT User.
+          To generate hashed password install <literal>mosquitto</literal>
+          package and use <literal>mosquitto_passwd</literal>.
+        '';
+      };
 
-      host = mkOption {
-        default = "127.0.0.1";
-        example = "0.0.0.0";
-        type = types.str;
+      hashedPasswordFile = mkOption {
+        type = uniq (nullOr types.path);
+        example = "/path/to/file";
+        default = null;
         description = ''
-          Host to listen on without SSL.
+          Specifies the path to a file containing the
+          hashed password for the MQTT user.
+          To generate hashed password install <literal>mosquitto</literal>
+          package and use <literal>mosquitto_passwd</literal>.
         '';
       };
 
-      port = mkOption {
-        default = 1883;
-        type = types.int;
+      acl = mkOption {
+        type = listOf str;
+        example = [ "read A/B" "readwrite A/#" ];
+        default = [];
         description = ''
-          Port on which to listen without SSL.
+          Control client access to topics on the broker.
         '';
       };
+    };
+  };
 
-      ssl = {
-        enable = mkEnableOption "SSL listener";
+  userAsserts = prefix: users:
+    mapAttrsToList
+      (n: _: {
+        assertion = builtins.match "[^:\r\n]+" n != null;
+        message = "Invalid user name ${n} in ${prefix}";
+      })
+      users
+    ++ mapAttrsToList
+      (n: u: {
+        assertion = count (s: s != null) [
+          u.password u.passwordFile u.hashedPassword u.hashedPasswordFile
+        ] <= 1;
+        message = "Cannot set more than one password option for user ${n} in ${prefix}";
+      }) users;
+
+  makePasswordFile = users: path:
+    let
+      makeLines = store: file:
+        mapAttrsToList
+          (n: u: "addLine ${escapeShellArg n} ${escapeShellArg u.${store}}")
+          (filterAttrs (_: u: u.${store} != null) users)
+        ++ mapAttrsToList
+          (n: u: "addFile ${escapeShellArg n} ${escapeShellArg "${u.${file}}"}")
+          (filterAttrs (_: u: u.${file} != null) users);
+      plainLines = makeLines "password" "passwordFile";
+      hashedLines = makeLines "hashedPassword" "hashedPasswordFile";
+    in
+      pkgs.writeScript "make-mosquitto-passwd"
+        (''
+          #! ${pkgs.runtimeShell}
+
+          set -eu
+
+          file=${escapeShellArg path}
+
+          rm -f "$file"
+          touch "$file"
+
+          addLine() {
+            echo "$1:$2" >> "$file"
+          }
+          addFile() {
+            if [ $(wc -l <"$2") -gt 1 ]; then
+              echo "invalid mosquitto password file $2" >&2
+              return 1
+            fi
+            echo "$1:$(cat "$2")" >> "$file"
+          }
+        ''
+        + concatStringsSep "\n"
+          (plainLines
+           ++ optional (plainLines != []) ''
+             ${pkgs.mosquitto}/bin/mosquitto_passwd -U "$file"
+           ''
+           ++ hashedLines));
+
+  makeACLFile = idx: users: supplement:
+    pkgs.writeText "mosquitto-acl-${toString idx}.conf"
+      (concatStringsSep
+        "\n"
+        (flatten [
+          supplement
+          (mapAttrsToList
+            (n: u: [ "user ${n}" ] ++ map (t: "topic ${t}") u.acl)
+            users)
+        ]));
+
+  authPluginOptions = with types; submodule {
+    options = {
+      plugin = mkOption {
+        type = path;
+        description = ''
+          Plugin path to load, should be a <literal>.so</literal> file.
+        '';
+      };
 
-        cafile = mkOption {
-          type = types.nullOr types.path;
-          default = null;
-          description = "Path to PEM encoded CA certificates.";
-        };
+      denySpecialChars = mkOption {
+        type = bool;
+        description = ''
+          Automatically disallow all clients using <literal>#</literal>
+          or <literal>+</literal> in their name/id.
+        '';
+        default = true;
+      };
 
-        certfile = mkOption {
-          type = types.nullOr types.path;
-          default = null;
-          description = "Path to PEM encoded server certificate.";
-        };
+      options = mkOption {
+        type = attrsOf optionType;
+        description = ''
+          Options for the auth plugin. Each key turns into a <literal>auth_opt_*</literal>
+           line in the config.
+        '';
+        default = {};
+      };
+    };
+  };
 
-        keyfile = mkOption {
-          type = types.nullOr types.path;
-          default = null;
-          description = "Path to PEM encoded server key.";
-        };
+  authAsserts = prefix: auth:
+    mapAttrsToList
+      (n: _: {
+        assertion = configKey.check n;
+        message = "Invalid auth plugin key ${prefix}.${n}";
+      })
+      auth;
+
+  formatAuthPlugin = plugin:
+    [
+      "auth_plugin ${plugin.plugin}"
+      "auth_plugin_deny_special_chars ${optionToString plugin.denySpecialChars}"
+    ]
+    ++ formatFreeform { prefix = "auth_opt_"; } plugin.options;
+
+  freeformListenerKeys = {
+    allow_anonymous = 1;
+    allow_zero_length_clientid = 1;
+    auto_id_prefix = 1;
+    cafile = 1;
+    capath = 1;
+    certfile = 1;
+    ciphers = 1;
+    "ciphers_tls1.3" = 1;
+    crlfile = 1;
+    dhparamfile = 1;
+    http_dir = 1;
+    keyfile = 1;
+    max_connections = 1;
+    max_qos = 1;
+    max_topic_alias = 1;
+    mount_point = 1;
+    protocol = 1;
+    psk_file = 1;
+    psk_hint = 1;
+    require_certificate = 1;
+    socket_domain = 1;
+    tls_engine = 1;
+    tls_engine_kpass_sha1 = 1;
+    tls_keyform = 1;
+    tls_version = 1;
+    use_identity_as_username = 1;
+    use_subject_as_username = 1;
+    use_username_as_clientid = 1;
+  };
 
-        host = mkOption {
-          default = "0.0.0.0";
-          example = "localhost";
-          type = types.str;
-          description = ''
-            Host to listen on with SSL.
-          '';
-        };
+  listenerOptions = with types; submodule {
+    options = {
+      port = mkOption {
+        type = port;
+        description = ''
+          Port to listen on. Must be set to 0 to listen on a unix domain socket.
+        '';
+        default = 1883;
+      };
 
-        port = mkOption {
-          default = 8883;
-          type = types.int;
-          description = ''
-            Port on which to listen with SSL.
-          '';
-        };
+      address = mkOption {
+        type = nullOr str;
+        description = ''
+          Address to listen on. Listen on <literal>0.0.0.0</literal>/<literal>::</literal>
+          when unset.
+        '';
+        default = null;
       };
 
-      dataDir = mkOption {
-        default = "/var/lib/mosquitto";
-        type = types.path;
+      authPlugins = mkOption {
+        type = listOf authPluginOptions;
         description = ''
-          The data directory.
+          Authentication plugin to attach to this listener.
+          Refer to the <link xlink:href="https://mosquitto.org/man/mosquitto-conf-5.html">
+          mosquitto.conf documentation</link> for details on authentication plugins.
         '';
+        default = [];
       };
 
       users = mkOption {
-        type = types.attrsOf (types.submodule {
-          options = {
-            password = mkOption {
-              type = with types; uniq (nullOr str);
-              default = null;
-              description = ''
-                Specifies the (clear text) password for the MQTT User.
-              '';
-            };
+        type = attrsOf userOptions;
+        example = { john = { password = "123456"; acl = [ "topic readwrite john/#" ]; }; };
+        description = ''
+          A set of users and their passwords and ACLs.
+        '';
+        default = {};
+      };
 
-            passwordFile = mkOption {
-              type = with types; uniq (nullOr str);
-              example = "/path/to/file";
-              default = null;
-              description = ''
-                Specifies the path to a file containing the
-                clear text password for the MQTT user.
-              '';
-            };
+      acl = mkOption {
+        type = listOf str;
+        description = ''
+          Additional ACL items to prepend to the generated ACL file.
+        '';
+        default = [];
+      };
 
-            hashedPassword = mkOption {
-              type = with types; uniq (nullOr str);
-              default = null;
-              description = ''
-                Specifies the hashed password for the MQTT User.
-                To generate hashed password install <literal>mosquitto</literal>
-                package and use <literal>mosquitto_passwd</literal>.
-              '';
-            };
+      settings = mkOption {
+        type = submodule {
+          freeformType = attrsOf optionType;
+        };
+        description = ''
+          Additional settings for this listener.
+        '';
+        default = {};
+      };
+    };
+  };
 
-            hashedPasswordFile = mkOption {
-              type = with types; uniq (nullOr str);
-              example = "/path/to/file";
-              default = null;
+  listenerAsserts = prefix: listener:
+    assertKeysValid prefix freeformListenerKeys listener.settings
+    ++ userAsserts prefix listener.users
+    ++ imap0
+      (i: v: authAsserts "${prefix}.authPlugins.${toString i}" v)
+      listener.authPlugins;
+
+  formatListener = idx: listener:
+    [
+      "listener ${toString listener.port} ${toString listener.address}"
+      "password_file ${cfg.dataDir}/passwd-${toString idx}"
+      "acl_file ${makeACLFile idx listener.users listener.acl}"
+    ]
+    ++ formatFreeform {} listener.settings
+    ++ concatMap formatAuthPlugin listener.authPlugins;
+
+  freeformBridgeKeys = {
+    bridge_alpn = 1;
+    bridge_attempt_unsubscribe = 1;
+    bridge_bind_address = 1;
+    bridge_cafile = 1;
+    bridge_capath = 1;
+    bridge_certfile = 1;
+    bridge_identity = 1;
+    bridge_insecure = 1;
+    bridge_keyfile = 1;
+    bridge_max_packet_size = 1;
+    bridge_outgoing_retain = 1;
+    bridge_protocol_version = 1;
+    bridge_psk = 1;
+    bridge_require_ocsp = 1;
+    bridge_tls_version = 1;
+    cleansession = 1;
+    idle_timeout = 1;
+    keepalive_interval = 1;
+    local_cleansession = 1;
+    local_clientid = 1;
+    local_password = 1;
+    local_username = 1;
+    notification_topic = 1;
+    notifications = 1;
+    notifications_local_only = 1;
+    remote_clientid = 1;
+    remote_password = 1;
+    remote_username = 1;
+    restart_timeout = 1;
+    round_robin = 1;
+    start_type = 1;
+    threshold = 1;
+    try_private = 1;
+  };
+
+  bridgeOptions = with types; submodule {
+    options = {
+      addresses = mkOption {
+        type = listOf (submodule {
+          options = {
+            address = mkOption {
+              type = str;
               description = ''
-                Specifies the path to a file containing the
-                hashed password for the MQTT user.
-                To generate hashed password install <literal>mosquitto</literal>
-                package and use <literal>mosquitto_passwd</literal>.
+                Address of the remote MQTT broker.
               '';
             };
 
-            acl = mkOption {
-              type = types.listOf types.str;
-              example = [ "topic read A/B" "topic A/#" ];
+            port = mkOption {
+              type = port;
               description = ''
-                Control client access to topics on the broker.
+                Port of the remote MQTT broker.
               '';
+              default = 1883;
             };
           };
         });
-        example = { john = { password = "123456"; acl = [ "topic readwrite john/#" ]; }; };
+        default = [];
         description = ''
-          A set of users and their passwords and ACLs.
+          Remote endpoints for the bridge.
         '';
       };
 
-      allowAnonymous = mkOption {
-        default = false;
-        type = types.bool;
+      topics = mkOption {
+        type = listOf str;
         description = ''
-          Allow clients to connect without authentication.
+          Topic patterns to be shared between the two brokers.
+          Refer to the <link xlink:href="https://mosquitto.org/man/mosquitto-conf-5.html">
+          mosquitto.conf documentation</link> for details on the format.
         '';
+        default = [];
+        example = [ "# both 2 local/topic/ remote/topic/" ];
       };
 
-      checkPasswords = mkOption {
-        default = false;
-        example = true;
-        type = types.bool;
+      settings = mkOption {
+        type = submodule {
+          freeformType = attrsOf optionType;
+        };
         description = ''
-          Refuse connection when clients provide incorrect passwords.
+          Additional settings for this bridge.
         '';
+        default = {};
       };
+    };
+  };
 
-      extraConf = mkOption {
-        default = "";
-        type = types.lines;
-        description = ''
-          Extra config to append to `mosquitto.conf` file.
-        '';
-      };
+  bridgeAsserts = prefix: bridge:
+    assertKeysValid prefix freeformBridgeKeys bridge.settings
+    ++ [ {
+      assertion = length bridge.addresses > 0;
+      message = "Bridge ${prefix} needs remote broker addresses";
+    } ];
+
+  formatBridge = name: bridge:
+    [
+      "connection ${name}"
+      "addresses ${concatMapStringsSep " " (a: "${a.address}:${toString a.port}") bridge.addresses}"
+    ]
+    ++ map (t: "topic ${t}") bridge.topics
+    ++ formatFreeform {} bridge.settings;
+
+  freeformGlobalKeys = {
+    allow_duplicate_messages = 1;
+    autosave_interval = 1;
+    autosave_on_changes = 1;
+    check_retain_source = 1;
+    connection_messages = 1;
+    log_facility = 1;
+    log_timestamp = 1;
+    log_timestamp_format = 1;
+    max_inflight_bytes = 1;
+    max_inflight_messages = 1;
+    max_keepalive = 1;
+    max_packet_size = 1;
+    max_queued_bytes = 1;
+    max_queued_messages = 1;
+    memory_limit = 1;
+    message_size_limit = 1;
+    persistence_file = 1;
+    persistence_location = 1;
+    persistent_client_expiration = 1;
+    pid_file = 1;
+    queue_qos0_messages = 1;
+    retain_available = 1;
+    set_tcp_nodelay = 1;
+    sys_interval = 1;
+    upgrade_outgoing_qos = 1;
+    websockets_headers_size = 1;
+    websockets_log_level = 1;
+  };
 
-      aclExtraConf = mkOption {
-        default = "";
-        type = types.lines;
-        description = ''
-          Extra config to prepend to the ACL file.
-        '';
-      };
+  globalOptions = with types; {
+    enable = mkEnableOption "the MQTT Mosquitto broker";
+
+    bridges = mkOption {
+      type = attrsOf bridgeOptions;
+      default = {};
+      description = ''
+        Bridges to build to other MQTT brokers.
+      '';
+    };
+
+    listeners = mkOption {
+      type = listOf listenerOptions;
+      default = {};
+      description = ''
+        Listeners to configure on this broker.
+      '';
+    };
+
+    includeDirs = mkOption {
+      type = listOf path;
+      description = ''
+        Directories to be scanned for further config files to include.
+        Directories will processed in the order given,
+        <literal>*.conf</literal> files in the directory will be
+        read in case-sensistive alphabetical order.
+      '';
+      default = [];
+    };
+
+    logDest = mkOption {
+      type = listOf (either path (enum [ "stdout" "stderr" "syslog" "topic" "dlt" ]));
+      description = ''
+        Destinations to send log messages to.
+      '';
+      default = [ "stderr" ];
+    };
+
+    logType = mkOption {
+      type = listOf (enum [ "debug" "error" "warning" "notice" "information"
+                            "subscribe" "unsubscribe" "websockets" "none" "all" ]);
+      description = ''
+        Types of messages to log.
+      '';
+      default = [];
+    };
+
+    persistence = mkOption {
+      type = bool;
+      description = ''
+        Enable persistent storage of subscriptions and messages.
+      '';
+      default = true;
+    };
 
+    dataDir = mkOption {
+      default = "/var/lib/mosquitto";
+      type = types.path;
+      description = ''
+        The data directory.
+      '';
+    };
+
+    settings = mkOption {
+      type = submodule {
+        freeformType = attrsOf optionType;
+      };
+      description = ''
+        Global configuration options for the mosquitto broker.
+      '';
+      default = {};
     };
   };
 
+  globalAsserts = prefix: cfg:
+    flatten [
+      (assertKeysValid prefix freeformGlobalKeys cfg.settings)
+      (imap0 (n: l: listenerAsserts "${prefix}.listener.${toString n}" l) cfg.listeners)
+      (mapAttrsToList (n: b: bridgeAsserts "${prefix}.bridge.${n}" b) cfg.bridges)
+    ];
+
+  formatGlobal = cfg:
+    [
+      "per_listener_settings true"
+      "persistence ${optionToString cfg.persistence}"
+    ]
+    ++ map
+      (d: if path.check d then "log_dest file ${d}" else "log_dest ${d}")
+      cfg.logDest
+    ++ map (t: "log_type ${t}") cfg.logType
+    ++ formatFreeform {} cfg.settings
+    ++ concatLists (imap0 formatListener cfg.listeners)
+    ++ concatLists (mapAttrsToList formatBridge cfg.bridges)
+    ++ map (d: "include_dir ${d}") cfg.includeDirs;
+
+  configFile = pkgs.writeText "mosquitto.conf"
+    (concatStringsSep "\n" (formatGlobal cfg));
+
+in
+
+{
+
+  ###### Interface
+
+  options.services.mosquitto = globalOptions;
 
   ###### Implementation
 
   config = mkIf cfg.enable {
 
-    assertions = mapAttrsToList (name: cfg: {
-      assertion = length (filter (s: s != null) (with cfg; [
-        password passwordFile hashedPassword hashedPasswordFile
-      ])) <= 1;
-      message = "Cannot set more than one password option";
-    }) cfg.users;
+    assertions = globalAsserts "services.mosquitto" cfg;
 
     systemd.services.mosquitto = {
       description = "Mosquitto MQTT Broker Daemon";
@@ -227,7 +555,7 @@ in
         RuntimeDirectory = "mosquitto";
         WorkingDirectory = cfg.dataDir;
         Restart = "on-failure";
-        ExecStart = "${pkgs.mosquitto}/bin/mosquitto -c ${mosquittoConf}";
+        ExecStart = "${pkgs.mosquitto}/bin/mosquitto -c ${configFile}";
         ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
 
         # Hardening
@@ -252,12 +580,34 @@ in
         ReadWritePaths = [
           cfg.dataDir
           "/tmp"  # mosquitto_passwd creates files in /tmp before moving them
-        ];
-        ReadOnlyPaths = with cfg.ssl; lib.optionals (enable) [
-          certfile
-          keyfile
-          cafile
-        ];
+        ] ++ filter path.check cfg.logDest;
+        ReadOnlyPaths =
+          map (p: "${p}")
+            (cfg.includeDirs
+             ++ filter
+               (v: v != null)
+               (flatten [
+                 (map
+                   (l: [
+                     (l.settings.psk_file or null)
+                     (l.settings.http_dir or null)
+                     (l.settings.cafile or null)
+                     (l.settings.capath or null)
+                     (l.settings.certfile or null)
+                     (l.settings.crlfile or null)
+                     (l.settings.dhparamfile or null)
+                     (l.settings.keyfile or null)
+                   ])
+                   cfg.listeners)
+                 (mapAttrsToList
+                   (_: b: [
+                     (b.settings.bridge_cafile or null)
+                     (b.settings.bridge_capath or null)
+                     (b.settings.bridge_certfile or null)
+                     (b.settings.bridge_keyfile or null)
+                   ])
+                   cfg.bridges)
+               ]));
         RemoveIPC = true;
         RestrictAddressFamilies = [
           "AF_UNIX"  # for sd_notify() call
@@ -275,20 +625,12 @@ in
         ];
         UMask = "0077";
       };
-      preStart = ''
-        rm -f ${cfg.dataDir}/passwd
-        touch ${cfg.dataDir}/passwd
-      '' + concatStringsSep "\n" (
-        mapAttrsToList (n: c:
-          if c.hashedPasswordFile != null then
-            "echo '${n}:'$(cat '${c.hashedPasswordFile}') >> ${cfg.dataDir}/passwd"
-          else if c.passwordFile != null then
-            "${pkgs.mosquitto}/bin/mosquitto_passwd -b ${cfg.dataDir}/passwd ${n} $(cat '${c.passwordFile}')"
-          else if c.hashedPassword != null then
-            "echo '${n}:${c.hashedPassword}' >> ${cfg.dataDir}/passwd"
-          else optionalString (c.password != null)
-            "${pkgs.mosquitto}/bin/mosquitto_passwd -b ${cfg.dataDir}/passwd ${n} '${c.password}'"
-        ) cfg.users);
+      preStart =
+        concatStringsSep
+          "\n"
+          (imap0
+            (idx: listener: makePasswordFile listener.users "${cfg.dataDir}/passwd-${toString idx}")
+            cfg.listeners);
     };
 
     users.users.mosquitto = {
@@ -302,4 +644,6 @@ in
     users.groups.mosquitto.gid = config.ids.gids.mosquitto;
 
   };
+
+  meta.maintainers = with lib.maintainers; [ pennae ];
 }