at 24.11-pre 21 kB view raw
1{ config, lib, pkgs, ...}: 2 3with lib; 4 5let 6 cfg = config.services.mosquitto; 7 8 # note that mosquitto config parsing is very simplistic as of may 2021. 9 # often times they'll e.g. strtok() a line, check the first two tokens, and ignore the rest. 10 # there's no escaping available either, so we have to prevent any being necessary. 11 str = types.strMatching "[^\r\n]*" // { 12 description = "single-line string"; 13 }; 14 path = types.addCheck types.path (p: str.check "${p}"); 15 configKey = types.strMatching "[^\r\n\t ]+"; 16 optionType = with types; oneOf [ str path bool int ] // { 17 description = "string, path, bool, or integer"; 18 }; 19 optionToString = v: 20 if isBool v then boolToString v 21 else if path.check v then "${v}" 22 else toString v; 23 24 assertKeysValid = prefix: valid: config: 25 mapAttrsToList 26 (n: _: { 27 assertion = valid ? ${n}; 28 message = "Invalid config key ${prefix}.${n}."; 29 }) 30 config; 31 32 formatFreeform = { prefix ? "" }: mapAttrsToList (n: v: "${prefix}${n} ${optionToString v}"); 33 34 userOptions = with types; submodule { 35 options = { 36 password = mkOption { 37 type = uniq (nullOr str); 38 default = null; 39 description = '' 40 Specifies the (clear text) password for the MQTT User. 41 ''; 42 }; 43 44 passwordFile = mkOption { 45 type = uniq (nullOr path); 46 example = "/path/to/file"; 47 default = null; 48 description = '' 49 Specifies the path to a file containing the 50 clear text password for the MQTT user. 51 The file is securely passed to mosquitto by 52 leveraging systemd credentials. No special 53 permissions need to be set on this file. 54 ''; 55 }; 56 57 hashedPassword = mkOption { 58 type = uniq (nullOr str); 59 default = null; 60 description = '' 61 Specifies the hashed password for the MQTT User. 62 To generate hashed password install the `mosquitto` 63 package and use `mosquitto_passwd`, then extract 64 the second field (after the `:`) from the generated 65 file. 66 ''; 67 }; 68 69 hashedPasswordFile = mkOption { 70 type = uniq (nullOr path); 71 example = "/path/to/file"; 72 default = null; 73 description = '' 74 Specifies the path to a file containing the 75 hashed password for the MQTT user. 76 To generate hashed password install the `mosquitto` 77 package and use `mosquitto_passwd`, then remove the 78 `username:` prefix from the generated file. 79 The file is securely passed to mosquitto by 80 leveraging systemd credentials. No special 81 permissions need to be set on this file. 82 ''; 83 }; 84 85 acl = mkOption { 86 type = listOf str; 87 example = [ "read A/B" "readwrite A/#" ]; 88 default = []; 89 description = '' 90 Control client access to topics on the broker. 91 ''; 92 }; 93 }; 94 }; 95 96 userAsserts = prefix: users: 97 mapAttrsToList 98 (n: _: { 99 assertion = builtins.match "[^:\r\n]+" n != null; 100 message = "Invalid user name ${n} in ${prefix}"; 101 }) 102 users 103 ++ mapAttrsToList 104 (n: u: { 105 assertion = count (s: s != null) [ 106 u.password u.passwordFile u.hashedPassword u.hashedPasswordFile 107 ] <= 1; 108 message = "Cannot set more than one password option for user ${n} in ${prefix}"; 109 }) users; 110 111 listenerScope = index: "listener-${toString index}"; 112 userScope = prefix: index: "${prefix}-user-${toString index}"; 113 credentialID = prefix: credential: "${prefix}-${credential}"; 114 115 toScopedUsers = listenerScope: users: pipe users [ 116 attrNames 117 (imap0 (index: user: nameValuePair user 118 (users.${user} // { scope = userScope listenerScope index; }) 119 )) 120 listToAttrs 121 ]; 122 123 userCredentials = user: credentials: pipe credentials [ 124 (filter (credential: user.${credential} != null)) 125 (map (credential: "${credentialID user.scope credential}:${user.${credential}}")) 126 ]; 127 usersCredentials = listenerScope: users: credentials: pipe users [ 128 (toScopedUsers listenerScope) 129 (mapAttrsToList (_: user: userCredentials user credentials)) 130 concatLists 131 ]; 132 systemdCredentials = listeners: listenerCredentials: pipe listeners [ 133 (imap0 (index: listener: listenerCredentials (listenerScope index) listener)) 134 concatLists 135 ]; 136 137 makePasswordFile = listenerScope: users: path: 138 let 139 makeLines = store: file: let 140 scopedUsers = toScopedUsers listenerScope users; 141 in 142 mapAttrsToList 143 (name: user: ''addLine ${escapeShellArg name} "''$(systemd-creds cat ${credentialID user.scope store})"'') 144 (filterAttrs (_: user: user.${store} != null) scopedUsers) 145 ++ mapAttrsToList 146 (name: user: ''addFile ${escapeShellArg name} "''${CREDENTIALS_DIRECTORY}/${credentialID user.scope file}"'') 147 (filterAttrs (_: user: user.${file} != null) scopedUsers); 148 plainLines = makeLines "password" "passwordFile"; 149 hashedLines = makeLines "hashedPassword" "hashedPasswordFile"; 150 in 151 pkgs.writeScript "make-mosquitto-passwd" 152 ('' 153 #! ${pkgs.runtimeShell} 154 155 set -eu 156 157 file=${escapeShellArg path} 158 159 rm -f "$file" 160 touch "$file" 161 162 addLine() { 163 echo "$1:$2" >> "$file" 164 } 165 addFile() { 166 if [ $(wc -l <"$2") -gt 1 ]; then 167 echo "invalid mosquitto password file $2" >&2 168 return 1 169 fi 170 echo "$1:$(cat "$2")" >> "$file" 171 } 172 '' 173 + concatStringsSep "\n" 174 (plainLines 175 ++ optional (plainLines != []) '' 176 ${cfg.package}/bin/mosquitto_passwd -U "$file" 177 '' 178 ++ hashedLines)); 179 180 authPluginOptions = with types; submodule { 181 options = { 182 plugin = mkOption { 183 type = path; 184 description = '' 185 Plugin path to load, should be a `.so` file. 186 ''; 187 }; 188 189 denySpecialChars = mkOption { 190 type = bool; 191 description = '' 192 Automatically disallow all clients using `#` 193 or `+` in their name/id. 194 ''; 195 default = true; 196 }; 197 198 options = mkOption { 199 type = attrsOf optionType; 200 description = '' 201 Options for the auth plugin. Each key turns into a `auth_opt_*` 202 line in the config. 203 ''; 204 default = {}; 205 }; 206 }; 207 }; 208 209 authAsserts = prefix: auth: 210 mapAttrsToList 211 (n: _: { 212 assertion = configKey.check n; 213 message = "Invalid auth plugin key ${prefix}.${n}"; 214 }) 215 auth; 216 217 formatAuthPlugin = plugin: 218 [ 219 "auth_plugin ${plugin.plugin}" 220 "auth_plugin_deny_special_chars ${optionToString plugin.denySpecialChars}" 221 ] 222 ++ formatFreeform { prefix = "auth_opt_"; } plugin.options; 223 224 freeformListenerKeys = { 225 allow_anonymous = 1; 226 allow_zero_length_clientid = 1; 227 auto_id_prefix = 1; 228 bind_interface = 1; 229 cafile = 1; 230 capath = 1; 231 certfile = 1; 232 ciphers = 1; 233 "ciphers_tls1.3" = 1; 234 crlfile = 1; 235 dhparamfile = 1; 236 http_dir = 1; 237 keyfile = 1; 238 max_connections = 1; 239 max_qos = 1; 240 max_topic_alias = 1; 241 mount_point = 1; 242 protocol = 1; 243 psk_file = 1; 244 psk_hint = 1; 245 require_certificate = 1; 246 socket_domain = 1; 247 tls_engine = 1; 248 tls_engine_kpass_sha1 = 1; 249 tls_keyform = 1; 250 tls_version = 1; 251 use_identity_as_username = 1; 252 use_subject_as_username = 1; 253 use_username_as_clientid = 1; 254 }; 255 256 listenerOptions = with types; submodule { 257 options = { 258 port = mkOption { 259 type = port; 260 description = '' 261 Port to listen on. Must be set to 0 to listen on a unix domain socket. 262 ''; 263 default = 1883; 264 }; 265 266 address = mkOption { 267 type = nullOr str; 268 description = '' 269 Address to listen on. Listen on `0.0.0.0`/`::` 270 when unset. 271 ''; 272 default = null; 273 }; 274 275 authPlugins = mkOption { 276 type = listOf authPluginOptions; 277 description = '' 278 Authentication plugin to attach to this listener. 279 Refer to the [mosquitto.conf documentation](https://mosquitto.org/man/mosquitto-conf-5.html) 280 for details on authentication plugins. 281 ''; 282 default = []; 283 }; 284 285 users = mkOption { 286 type = attrsOf userOptions; 287 example = { john = { password = "123456"; acl = [ "readwrite john/#" ]; }; }; 288 description = '' 289 A set of users and their passwords and ACLs. 290 ''; 291 default = {}; 292 }; 293 294 omitPasswordAuth = mkOption { 295 type = bool; 296 description = '' 297 Omits password checking, allowing anyone to log in with any user name unless 298 other mandatory authentication methods (eg TLS client certificates) are configured. 299 ''; 300 default = false; 301 }; 302 303 acl = mkOption { 304 type = listOf str; 305 description = '' 306 Additional ACL items to prepend to the generated ACL file. 307 ''; 308 example = [ "pattern read #" "topic readwrite anon/report/#" ]; 309 default = []; 310 }; 311 312 settings = mkOption { 313 type = submodule { 314 freeformType = attrsOf optionType; 315 }; 316 description = '' 317 Additional settings for this listener. 318 ''; 319 default = {}; 320 }; 321 }; 322 }; 323 324 listenerAsserts = prefix: listener: 325 assertKeysValid "${prefix}.settings" freeformListenerKeys listener.settings 326 ++ userAsserts prefix listener.users 327 ++ imap0 328 (i: v: authAsserts "${prefix}.authPlugins.${toString i}" v) 329 listener.authPlugins; 330 331 formatListener = idx: listener: 332 [ 333 "listener ${toString listener.port} ${toString listener.address}" 334 "acl_file /etc/mosquitto/acl-${toString idx}.conf" 335 ] 336 ++ optional (! listener.omitPasswordAuth) "password_file ${cfg.dataDir}/passwd-${toString idx}" 337 ++ formatFreeform {} listener.settings 338 ++ concatMap formatAuthPlugin listener.authPlugins; 339 340 freeformBridgeKeys = { 341 bridge_alpn = 1; 342 bridge_attempt_unsubscribe = 1; 343 bridge_bind_address = 1; 344 bridge_cafile = 1; 345 bridge_capath = 1; 346 bridge_certfile = 1; 347 bridge_identity = 1; 348 bridge_insecure = 1; 349 bridge_keyfile = 1; 350 bridge_max_packet_size = 1; 351 bridge_outgoing_retain = 1; 352 bridge_protocol_version = 1; 353 bridge_psk = 1; 354 bridge_require_ocsp = 1; 355 bridge_tls_version = 1; 356 cleansession = 1; 357 idle_timeout = 1; 358 keepalive_interval = 1; 359 local_cleansession = 1; 360 local_clientid = 1; 361 local_password = 1; 362 local_username = 1; 363 notification_topic = 1; 364 notifications = 1; 365 notifications_local_only = 1; 366 remote_clientid = 1; 367 remote_password = 1; 368 remote_username = 1; 369 restart_timeout = 1; 370 round_robin = 1; 371 start_type = 1; 372 threshold = 1; 373 try_private = 1; 374 }; 375 376 bridgeOptions = with types; submodule { 377 options = { 378 addresses = mkOption { 379 type = listOf (submodule { 380 options = { 381 address = mkOption { 382 type = str; 383 description = '' 384 Address of the remote MQTT broker. 385 ''; 386 }; 387 388 port = mkOption { 389 type = port; 390 description = '' 391 Port of the remote MQTT broker. 392 ''; 393 default = 1883; 394 }; 395 }; 396 }); 397 default = []; 398 description = '' 399 Remote endpoints for the bridge. 400 ''; 401 }; 402 403 topics = mkOption { 404 type = listOf str; 405 description = '' 406 Topic patterns to be shared between the two brokers. 407 Refer to the [ 408 mosquitto.conf documentation](https://mosquitto.org/man/mosquitto-conf-5.html) for details on the format. 409 ''; 410 default = []; 411 example = [ "# both 2 local/topic/ remote/topic/" ]; 412 }; 413 414 settings = mkOption { 415 type = submodule { 416 freeformType = attrsOf optionType; 417 }; 418 description = '' 419 Additional settings for this bridge. 420 ''; 421 default = {}; 422 }; 423 }; 424 }; 425 426 bridgeAsserts = prefix: bridge: 427 assertKeysValid "${prefix}.settings" freeformBridgeKeys bridge.settings 428 ++ [ { 429 assertion = length bridge.addresses > 0; 430 message = "Bridge ${prefix} needs remote broker addresses"; 431 } ]; 432 433 formatBridge = name: bridge: 434 [ 435 "connection ${name}" 436 "addresses ${concatMapStringsSep " " (a: "${a.address}:${toString a.port}") bridge.addresses}" 437 ] 438 ++ map (t: "topic ${t}") bridge.topics 439 ++ formatFreeform {} bridge.settings; 440 441 freeformGlobalKeys = { 442 allow_duplicate_messages = 1; 443 autosave_interval = 1; 444 autosave_on_changes = 1; 445 check_retain_source = 1; 446 connection_messages = 1; 447 log_facility = 1; 448 log_timestamp = 1; 449 log_timestamp_format = 1; 450 max_inflight_bytes = 1; 451 max_inflight_messages = 1; 452 max_keepalive = 1; 453 max_packet_size = 1; 454 max_queued_bytes = 1; 455 max_queued_messages = 1; 456 memory_limit = 1; 457 message_size_limit = 1; 458 persistence_file = 1; 459 persistence_location = 1; 460 persistent_client_expiration = 1; 461 pid_file = 1; 462 queue_qos0_messages = 1; 463 retain_available = 1; 464 set_tcp_nodelay = 1; 465 sys_interval = 1; 466 upgrade_outgoing_qos = 1; 467 websockets_headers_size = 1; 468 websockets_log_level = 1; 469 }; 470 471 globalOptions = with types; { 472 enable = mkEnableOption "the MQTT Mosquitto broker"; 473 474 package = mkPackageOption pkgs "mosquitto" { }; 475 476 bridges = mkOption { 477 type = attrsOf bridgeOptions; 478 default = {}; 479 description = '' 480 Bridges to build to other MQTT brokers. 481 ''; 482 }; 483 484 listeners = mkOption { 485 type = listOf listenerOptions; 486 default = {}; 487 description = '' 488 Listeners to configure on this broker. 489 ''; 490 }; 491 492 includeDirs = mkOption { 493 type = listOf path; 494 description = '' 495 Directories to be scanned for further config files to include. 496 Directories will processed in the order given, 497 `*.conf` files in the directory will be 498 read in case-sensitive alphabetical order. 499 ''; 500 default = []; 501 }; 502 503 logDest = mkOption { 504 type = listOf (either path (enum [ "stdout" "stderr" "syslog" "topic" "dlt" ])); 505 description = '' 506 Destinations to send log messages to. 507 ''; 508 default = [ "stderr" ]; 509 }; 510 511 logType = mkOption { 512 type = listOf (enum [ "debug" "error" "warning" "notice" "information" 513 "subscribe" "unsubscribe" "websockets" "none" "all" ]); 514 description = '' 515 Types of messages to log. 516 ''; 517 default = []; 518 }; 519 520 persistence = mkOption { 521 type = bool; 522 description = '' 523 Enable persistent storage of subscriptions and messages. 524 ''; 525 default = true; 526 }; 527 528 dataDir = mkOption { 529 default = "/var/lib/mosquitto"; 530 type = types.path; 531 description = '' 532 The data directory. 533 ''; 534 }; 535 536 settings = mkOption { 537 type = submodule { 538 freeformType = attrsOf optionType; 539 }; 540 description = '' 541 Global configuration options for the mosquitto broker. 542 ''; 543 default = {}; 544 }; 545 }; 546 547 globalAsserts = prefix: cfg: 548 flatten [ 549 (assertKeysValid "${prefix}.settings" freeformGlobalKeys cfg.settings) 550 (imap0 (n: l: listenerAsserts "${prefix}.listener.${toString n}" l) cfg.listeners) 551 (mapAttrsToList (n: b: bridgeAsserts "${prefix}.bridge.${n}" b) cfg.bridges) 552 ]; 553 554 formatGlobal = cfg: 555 [ 556 "per_listener_settings true" 557 "persistence ${optionToString cfg.persistence}" 558 ] 559 ++ map 560 (d: if path.check d then "log_dest file ${d}" else "log_dest ${d}") 561 cfg.logDest 562 ++ map (t: "log_type ${t}") cfg.logType 563 ++ formatFreeform {} cfg.settings 564 ++ concatLists (imap0 formatListener cfg.listeners) 565 ++ concatLists (mapAttrsToList formatBridge cfg.bridges) 566 ++ map (d: "include_dir ${d}") cfg.includeDirs; 567 568 configFile = pkgs.writeText "mosquitto.conf" 569 (concatStringsSep "\n" (formatGlobal cfg)); 570 571in 572 573{ 574 575 ###### Interface 576 577 options.services.mosquitto = globalOptions; 578 579 ###### Implementation 580 581 config = mkIf cfg.enable { 582 583 assertions = globalAsserts "services.mosquitto" cfg; 584 585 systemd.services.mosquitto = { 586 description = "Mosquitto MQTT Broker Daemon"; 587 wantedBy = [ "multi-user.target" ]; 588 wants = [ "network-online.target" ]; 589 after = [ "network-online.target" ]; 590 serviceConfig = { 591 Type = "notify"; 592 NotifyAccess = "main"; 593 User = "mosquitto"; 594 Group = "mosquitto"; 595 RuntimeDirectory = "mosquitto"; 596 WorkingDirectory = cfg.dataDir; 597 Restart = "on-failure"; 598 ExecStart = "${cfg.package}/bin/mosquitto -c ${configFile}"; 599 ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; 600 601 # Credentials 602 SetCredential = let 603 listenerCredentials = listenerScope: listener: 604 usersCredentials listenerScope listener.users [ "password" "hashedPassword" ]; 605 in 606 systemdCredentials cfg.listeners listenerCredentials; 607 608 LoadCredential = let 609 listenerCredentials = listenerScope: listener: 610 usersCredentials listenerScope listener.users [ "passwordFile" "hashedPasswordFile" ]; 611 in 612 systemdCredentials cfg.listeners listenerCredentials; 613 614 # Hardening 615 CapabilityBoundingSet = ""; 616 DevicePolicy = "closed"; 617 LockPersonality = true; 618 MemoryDenyWriteExecute = true; 619 NoNewPrivileges = true; 620 PrivateDevices = true; 621 PrivateTmp = true; 622 PrivateUsers = true; 623 ProtectClock = true; 624 ProtectControlGroups = true; 625 ProtectHome = true; 626 ProtectHostname = true; 627 ProtectKernelLogs = true; 628 ProtectKernelModules = true; 629 ProtectKernelTunables = true; 630 ProtectProc = "invisible"; 631 ProcSubset = "pid"; 632 ProtectSystem = "strict"; 633 ReadWritePaths = [ 634 cfg.dataDir 635 "/tmp" # mosquitto_passwd creates files in /tmp before moving them 636 ] ++ filter path.check cfg.logDest; 637 ReadOnlyPaths = 638 map (p: "${p}") 639 (cfg.includeDirs 640 ++ filter 641 (v: v != null) 642 (flatten [ 643 (map 644 (l: [ 645 (l.settings.psk_file or null) 646 (l.settings.http_dir or null) 647 (l.settings.cafile or null) 648 (l.settings.capath or null) 649 (l.settings.certfile or null) 650 (l.settings.crlfile or null) 651 (l.settings.dhparamfile or null) 652 (l.settings.keyfile or null) 653 ]) 654 cfg.listeners) 655 (mapAttrsToList 656 (_: b: [ 657 (b.settings.bridge_cafile or null) 658 (b.settings.bridge_capath or null) 659 (b.settings.bridge_certfile or null) 660 (b.settings.bridge_keyfile or null) 661 ]) 662 cfg.bridges) 663 ])); 664 RemoveIPC = true; 665 RestrictAddressFamilies = [ 666 "AF_UNIX" 667 "AF_INET" 668 "AF_INET6" 669 "AF_NETLINK" 670 ]; 671 RestrictNamespaces = true; 672 RestrictRealtime = true; 673 RestrictSUIDSGID = true; 674 SystemCallArchitectures = "native"; 675 SystemCallFilter = [ 676 "@system-service" 677 "~@privileged" 678 "~@resources" 679 ]; 680 UMask = "0077"; 681 }; 682 preStart = 683 concatStringsSep 684 "\n" 685 (imap0 686 (idx: listener: makePasswordFile (listenerScope idx) listener.users "${cfg.dataDir}/passwd-${toString idx}") 687 cfg.listeners); 688 }; 689 690 environment.etc = listToAttrs ( 691 imap0 692 (idx: listener: { 693 name = "mosquitto/acl-${toString idx}.conf"; 694 value = { 695 user = config.users.users.mosquitto.name; 696 group = config.users.users.mosquitto.group; 697 mode = "0400"; 698 text = (concatStringsSep 699 "\n" 700 (flatten [ 701 listener.acl 702 (mapAttrsToList 703 (n: u: [ "user ${n}" ] ++ map (t: "topic ${t}") u.acl) 704 listener.users) 705 ])); 706 }; 707 }) 708 cfg.listeners 709 ); 710 711 users.users.mosquitto = { 712 description = "Mosquitto MQTT Broker Daemon owner"; 713 group = "mosquitto"; 714 uid = config.ids.uids.mosquitto; 715 home = cfg.dataDir; 716 createHome = true; 717 }; 718 719 users.groups.mosquitto.gid = config.ids.gids.mosquitto; 720 721 }; 722 723 meta = { 724 maintainers = with lib.maintainers; [ pennae ]; 725 doc = ./mosquitto.md; 726 }; 727}