{ config, lib, pkgs, ...}: with lib; let cfg = config.services.mosquitto; # 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. ''; }; 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 { ###### Interface options.services.mosquitto = globalOptions; ###### Implementation config = mkIf cfg.enable { assertions = globalAsserts "services.mosquitto" cfg; systemd.services.mosquitto = { description = "Mosquitto MQTT Broker Daemon"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; serviceConfig = { Type = "notify"; NotifyAccess = "main"; User = "mosquitto"; Group = "mosquitto"; RuntimeDirectory = "mosquitto"; WorkingDirectory = cfg.dataDir; Restart = "on-failure"; ExecStart = "${pkgs.mosquitto}/bin/mosquitto -c ${configFile}"; ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; # Hardening CapabilityBoundingSet = ""; DevicePolicy = "closed"; LockPersonality = true; MemoryDenyWriteExecute = true; NoNewPrivileges = true; PrivateDevices = true; PrivateTmp = true; PrivateUsers = true; ProtectClock = true; ProtectControlGroups = true; ProtectHome = true; ProtectHostname = true; ProtectKernelLogs = true; ProtectKernelModules = true; ProtectKernelTunables = true; ProtectProc = "invisible"; ProcSubset = "pid"; ProtectSystem = "strict"; ReadWritePaths = [ cfg.dataDir "/tmp" # mosquitto_passwd creates files in /tmp before moving them ] ++ 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 "AF_INET" "AF_INET6" ]; RestrictNamespaces = true; RestrictRealtime = true; RestrictSUIDSGID = true; SystemCallArchitectures = "native"; SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; UMask = "0077"; }; preStart = concatStringsSep "\n" (imap0 (idx: listener: makePasswordFile listener.users "${cfg.dataDir}/passwd-${toString idx}") cfg.listeners); }; users.users.mosquitto = { description = "Mosquitto MQTT Broker Daemon owner"; group = "mosquitto"; uid = config.ids.uids.mosquitto; home = cfg.dataDir; createHome = true; }; users.groups.mosquitto.gid = config.ids.gids.mosquitto; }; meta.maintainers = with lib.maintainers; [ pennae ]; }