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 4bbd46428524..cbcc3cb7cfcd 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. 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. + +
diff --git a/nixos/doc/manual/release-notes/rl-2111.section.md b/nixos/doc/manual/release-notes/rl-2111.section.md index 36d03fd0b59b..982f87daecdc 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 b0fbfc194083..5a573cbf4ac9 100644 --- a/nixos/modules/services/networking/mosquitto.nix +++ b/nixos/modules/services/networking/mosquitto.nix @@ -5,35 +5,529 @@ 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} - ''; + # 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; - passwordConf = optionalString cfg.checkPasswords '' - password_file ${cfg.dataDir}/passwd - ''; + assertKeysValid = prefix: valid: config: + mapAttrsToList + (n: _: { + assertion = valid ? ${n}; + message = "Invalid config key ${prefix}.${n}."; + }) + config; - 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} - ''; + formatFreeform = { prefix ? "" }: mapAttrsToList (n: v: "${prefix}${n} ${optionToString v}"); - userAcl = (concatStringsSep "\n\n" (mapAttrsToList (n: c: - "user ${n}\n" + (concatStringsSep "\n" c.acl)) cfg.users - )); + userOptions = with types; submodule { + options = { + password = mkOption { + type = uniq (nullOr str); + default = null; + description = '' + Specifies the (clear text) password for the MQTT User. + ''; + }; - aclFile = pkgs.writeText "mosquitto.acl" '' - ${cfg.aclExtraConf} - ${userAcl} - ''; + 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. + ''; + }; + + hashedPassword = mkOption { + type = uniq (nullOr str); + default = null; + description = '' + Specifies the hashed password for the MQTT User. + To generate hashed password install mosquitto + package and use mosquitto_passwd. + ''; + }; + + hashedPasswordFile = mkOption { + type = uniq (nullOr types.path); + example = "/path/to/file"; + default = null; + description = '' + Specifies the path to a file containing the + hashed password for the MQTT user. + To generate hashed password install mosquitto + package and use mosquitto_passwd. + ''; + }; + + acl = mkOption { + type = listOf str; + example = [ "read A/B" "readwrite A/#" ]; + default = []; + description = '' + Control client access to topics on the broker. + ''; + }; + }; + }; + + 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 .so file. + ''; + }; + + denySpecialChars = mkOption { + type = bool; + description = '' + Automatically disallow all clients using # + or + in their name/id. + ''; + default = true; + }; + + options = mkOption { + type = attrsOf optionType; + description = '' + Options for the auth plugin. Each key turns into a auth_opt_* + line in the config. + ''; + default = {}; + }; + }; + }; + + 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; + }; + + 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; + }; + + address = mkOption { + type = nullOr str; + description = '' + Address to listen on. Listen on 0.0.0.0/:: + when unset. + ''; + default = null; + }; + + authPlugins = mkOption { + type = listOf authPluginOptions; + description = '' + Authentication plugin to attach to this listener. + Refer to the + mosquitto.conf documentation for details on authentication plugins. + ''; + default = []; + }; + + users = mkOption { + type = attrsOf userOptions; + example = { john = { password = "123456"; acl = [ "topic readwrite john/#" ]; }; }; + description = '' + A set of users and their passwords and ACLs. + ''; + default = {}; + }; + + acl = mkOption { + type = listOf str; + description = '' + Additional ACL items to prepend to the generated ACL file. + ''; + default = []; + }; + + settings = mkOption { + type = submodule { + freeformType = attrsOf optionType; + }; + description = '' + Additional settings for this listener. + ''; + default = {}; + }; + }; + }; + + 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 = '' + Address of the remote MQTT broker. + ''; + }; + + port = mkOption { + type = port; + description = '' + Port of the remote MQTT broker. + ''; + default = 1883; + }; + }; + }); + default = []; + description = '' + Remote endpoints for the bridge. + ''; + }; + + topics = mkOption { + type = listOf str; + description = '' + Topic patterns to be shared between the two brokers. + Refer to the + mosquitto.conf documentation for details on the format. + ''; + default = []; + example = [ "# both 2 local/topic/ remote/topic/" ]; + }; + + settings = mkOption { + type = submodule { + freeformType = attrsOf optionType; + }; + description = '' + Additional settings for this bridge. + ''; + default = {}; + }; + }; + }; + + 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; + }; + + 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, + *.conf 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 @@ -41,179 +535,13 @@ in ###### Interface - options = { - services.mosquitto = { - enable = mkEnableOption "the MQTT Mosquitto broker"; - - host = mkOption { - default = "127.0.0.1"; - example = "0.0.0.0"; - type = types.str; - description = '' - Host to listen on without SSL. - ''; - }; - - port = mkOption { - default = 1883; - type = types.int; - description = '' - Port on which to listen without SSL. - ''; - }; - - ssl = { - enable = mkEnableOption "SSL listener"; - - cafile = mkOption { - type = types.nullOr types.path; - default = null; - description = "Path to PEM encoded CA certificates."; - }; - - certfile = mkOption { - type = types.nullOr types.path; - default = null; - description = "Path to PEM encoded server certificate."; - }; - - keyfile = mkOption { - type = types.nullOr types.path; - default = null; - description = "Path to PEM encoded server key."; - }; - - host = mkOption { - default = "0.0.0.0"; - example = "localhost"; - type = types.str; - description = '' - Host to listen on with SSL. - ''; - }; - - port = mkOption { - default = 8883; - type = types.int; - description = '' - Port on which to listen with SSL. - ''; - }; - }; - - dataDir = mkOption { - default = "/var/lib/mosquitto"; - type = types.path; - description = '' - The data directory. - ''; - }; - - 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. - ''; - }; - - 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. - ''; - }; - - hashedPassword = mkOption { - type = with types; uniq (nullOr str); - default = null; - description = '' - Specifies the hashed password for the MQTT User. - To generate hashed password install mosquitto - package and use mosquitto_passwd. - ''; - }; - - hashedPasswordFile = mkOption { - type = with types; uniq (nullOr str); - example = "/path/to/file"; - default = null; - description = '' - Specifies the path to a file containing the - hashed password for the MQTT user. - To generate hashed password install mosquitto - package and use mosquitto_passwd. - ''; - }; - - acl = mkOption { - type = types.listOf types.str; - example = [ "topic read A/B" "topic A/#" ]; - description = '' - Control client access to topics on the broker. - ''; - }; - }; - }); - example = { john = { password = "123456"; acl = [ "topic readwrite john/#" ]; }; }; - description = '' - A set of users and their passwords and ACLs. - ''; - }; - - allowAnonymous = mkOption { - default = false; - type = types.bool; - description = '' - Allow clients to connect without authentication. - ''; - }; - - checkPasswords = mkOption { - default = false; - example = true; - type = types.bool; - description = '' - Refuse connection when clients provide incorrect passwords. - ''; - }; - - extraConf = mkOption { - default = ""; - type = types.lines; - description = '' - Extra config to append to `mosquitto.conf` file. - ''; - }; - - aclExtraConf = mkOption { - default = ""; - type = types.lines; - description = '' - Extra config to prepend to the ACL file. - ''; - }; - - }; - }; - + 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 699be8fd7dc6..0894736bac9c 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 e29bd559ed9b..1a534184066c 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