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