summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--nixos/doc/manual/from_md/release-notes/rl-2111.section.xml8
-rw-r--r--nixos/doc/manual/release-notes/rl-2111.section.md3
-rw-r--r--nixos/modules/services/networking/mosquitto.nix690
-rw-r--r--nixos/tests/home-assistant.nix13
-rw-r--r--nixos/tests/mosquitto.nix20
5 files changed, 546 insertions, 188 deletions
diff --git a/nixos/doc/manual/from_md/release-notes/rl-2111.section.xml b/nixos/doc/manual/from_md/release-notes/rl-2111.section.xml
index 4bbd4642852..cbcc3cb7cfc 100644
--- a/nixos/doc/manual/from_md/release-notes/rl-2111.section.xml
+++ b/nixos/doc/manual/from_md/release-notes/rl-2111.section.xml
@@ -1138,6 +1138,14 @@ Superuser created successfully.
           <literal>coursier</literal>, you can create a shell alias.
         </para>
       </listitem>
+      <listitem>
+        <para>
+          The <literal>services.mosquitto</literal> module has been
+          rewritten to support multiple listeners and per-listener
+          configuration. Module configurations from previous releases
+          will no longer work and must be updated.
+        </para>
+      </listitem>
     </itemizedlist>
   </section>
   <section xml:id="sec-release-21.11-notable-changes">
diff --git a/nixos/doc/manual/release-notes/rl-2111.section.md b/nixos/doc/manual/release-notes/rl-2111.section.md
index 36d03fd0b59..982f87daecd 100644
--- a/nixos/doc/manual/release-notes/rl-2111.section.md
+++ b/nixos/doc/manual/release-notes/rl-2111.section.md
@@ -351,6 +351,9 @@ In addition to numerous new and upgraded packages, this release has the followin
 
 - The `coursier` package's binary was renamed from `coursier` to `cs`. Completions which haven't worked for a while should now work with the renamed binary. To keep using `coursier`, you can create a shell alias.
 
+- The `services.mosquitto` module has been rewritten to support multiple listeners and per-listener configuration.
+  Module configurations from previous releases will no longer work and must be updated.
+
 ## Other Notable Changes {#sec-release-21.11-notable-changes}
 
 
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 ];
 }
diff --git a/nixos/tests/home-assistant.nix b/nixos/tests/home-assistant.nix
index 699be8fd7dc..0894736bac9 100644
--- a/nixos/tests/home-assistant.nix
+++ b/nixos/tests/home-assistant.nix
@@ -12,13 +12,14 @@ in {
     environment.systemPackages = with pkgs; [ mosquitto ];
     services.mosquitto = {
       enable = true;
-      checkPasswords = true;
-      users = {
-        "${mqttUsername}" = {
-          acl = [ "topic readwrite #" ];
-          password = mqttPassword;
+      listeners = [ {
+        users = {
+          "${mqttUsername}" = {
+            acl = [ "readwrite #" ];
+            password = mqttPassword;
+          };
         };
-      };
+      } ];
     };
     services.home-assistant = {
       inherit configDir;
diff --git a/nixos/tests/mosquitto.nix b/nixos/tests/mosquitto.nix
index e29bd559ed9..1a534184066 100644
--- a/nixos/tests/mosquitto.nix
+++ b/nixos/tests/mosquitto.nix
@@ -19,16 +19,18 @@ in {
     server = { pkgs, ... }: {
       networking.firewall.allowedTCPPorts = [ port ];
       services.mosquitto = {
-        inherit port;
         enable = true;
-        host = "0.0.0.0";
-        checkPasswords = true;
-        users.${username} = {
-          inherit password;
-          acl = [
-            "topic readwrite ${topic}"
-          ];
-        };
+        listeners = [
+          {
+            inherit port;
+            users.${username} = {
+              inherit password;
+              acl = [
+                "readwrite ${topic}"
+              ];
+            };
+          }
+        ];
       };
 
       # disable private /tmp for this test