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