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 retain_expiry_interval = 1;
522 set_tcp_nodelay = 1;
523 sys_interval = 1;
524 upgrade_outgoing_qos = 1;
525 websockets_headers_size = 1;
526 websockets_log_level = 1;
527 };
528
529 globalOptions = with lib.types; {
530 enable = lib.mkEnableOption "the MQTT Mosquitto broker";
531
532 package = lib.mkPackageOption pkgs "mosquitto" { };
533
534 bridges = lib.mkOption {
535 type = attrsOf bridgeOptions;
536 default = { };
537 description = ''
538 Bridges to build to other MQTT brokers.
539 '';
540 };
541
542 listeners = lib.mkOption {
543 type = listOf listenerOptions;
544 default = [ ];
545 description = ''
546 Listeners to configure on this broker.
547 '';
548 };
549
550 includeDirs = lib.mkOption {
551 type = listOf path;
552 description = ''
553 Directories to be scanned for further config files to include.
554 Directories will processed in the order given,
555 `*.conf` files in the directory will be
556 read in case-sensitive alphabetical order.
557 '';
558 default = [ ];
559 };
560
561 logDest = lib.mkOption {
562 type = listOf (
563 either path (enum [
564 "stdout"
565 "stderr"
566 "syslog"
567 "topic"
568 "dlt"
569 ])
570 );
571 description = ''
572 Destinations to send log messages to.
573 '';
574 default = [ "stderr" ];
575 };
576
577 logType = lib.mkOption {
578 type = listOf (enum [
579 "debug"
580 "error"
581 "warning"
582 "notice"
583 "information"
584 "subscribe"
585 "unsubscribe"
586 "websockets"
587 "none"
588 "all"
589 ]);
590 description = ''
591 Types of messages to log.
592 '';
593 default = [ ];
594 };
595
596 persistence = lib.mkOption {
597 type = bool;
598 description = ''
599 Enable persistent storage of subscriptions and messages.
600 '';
601 default = true;
602 };
603
604 dataDir = lib.mkOption {
605 default = "/var/lib/mosquitto";
606 type = lib.types.path;
607 description = ''
608 The data directory.
609 '';
610 };
611
612 settings = lib.mkOption {
613 type = submodule {
614 freeformType = attrsOf optionType;
615 };
616 description = ''
617 Global configuration options for the mosquitto broker.
618 '';
619 default = { };
620 };
621 };
622
623 globalAsserts =
624 prefix: cfg:
625 lib.flatten [
626 (assertKeysValid "${prefix}.settings" freeformGlobalKeys cfg.settings)
627 (lib.imap0 (n: l: listenerAsserts "${prefix}.listener.${toString n}" l) cfg.listeners)
628 (lib.mapAttrsToList (n: b: bridgeAsserts "${prefix}.bridge.${n}" b) cfg.bridges)
629 ];
630
631 formatGlobal =
632 cfg:
633 [
634 "per_listener_settings true"
635 "persistence ${optionToString cfg.persistence}"
636 ]
637 ++ map (d: if path.check d then "log_dest file ${d}" else "log_dest ${d}") cfg.logDest
638 ++ map (t: "log_type ${t}") cfg.logType
639 ++ formatFreeform { } cfg.settings
640 ++ lib.concatLists (lib.imap0 formatListener cfg.listeners)
641 ++ lib.concatLists (lib.mapAttrsToList formatBridge cfg.bridges)
642 ++ map (d: "include_dir ${d}") cfg.includeDirs;
643
644 configFile = pkgs.writeText "mosquitto.conf" (lib.concatStringsSep "\n" (formatGlobal cfg));
645
646in
647
648{
649
650 ###### Interface
651
652 options.services.mosquitto = globalOptions;
653
654 ###### Implementation
655
656 config = lib.mkIf cfg.enable {
657
658 assertions = globalAsserts "services.mosquitto" cfg;
659
660 systemd.services.mosquitto = {
661 description = "Mosquitto MQTT Broker Daemon";
662 wantedBy = [ "multi-user.target" ];
663 wants = [ "network-online.target" ];
664 after = [ "network-online.target" ];
665 serviceConfig = {
666 Type = "notify";
667 NotifyAccess = "main";
668 User = "mosquitto";
669 Group = "mosquitto";
670 RuntimeDirectory = "mosquitto";
671 WorkingDirectory = cfg.dataDir;
672 Restart = "on-failure";
673 ExecStart = "${cfg.package}/bin/mosquitto -c ${configFile}";
674 ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
675
676 # Credentials
677 SetCredential =
678 let
679 listenerCredentials =
680 listenerScope: listener:
681 usersCredentials listenerScope listener.users [
682 "password"
683 "hashedPassword"
684 ];
685 in
686 systemdCredentials cfg.listeners listenerCredentials;
687
688 LoadCredential =
689 let
690 listenerCredentials =
691 listenerScope: listener:
692 usersCredentials listenerScope listener.users [
693 "passwordFile"
694 "hashedPasswordFile"
695 ];
696 in
697 systemdCredentials cfg.listeners listenerCredentials;
698
699 # Hardening
700 CapabilityBoundingSet = "";
701 DevicePolicy = "closed";
702 LockPersonality = true;
703 MemoryDenyWriteExecute = true;
704 NoNewPrivileges = true;
705 PrivateDevices = true;
706 PrivateTmp = true;
707 PrivateUsers = true;
708 ProtectClock = true;
709 ProtectControlGroups = true;
710 ProtectHome = true;
711 ProtectHostname = true;
712 ProtectKernelLogs = true;
713 ProtectKernelModules = true;
714 ProtectKernelTunables = true;
715 ProtectProc = "invisible";
716 ProcSubset = "pid";
717 ProtectSystem = "strict";
718 ReadWritePaths = [
719 cfg.dataDir
720 "/tmp" # mosquitto_passwd creates files in /tmp before moving them
721 ]
722 ++ lib.filter path.check cfg.logDest;
723 ReadOnlyPaths = map (p: "${p}") (
724 cfg.includeDirs
725 ++ lib.filter (v: v != null) (
726 lib.flatten [
727 (map (l: [
728 (l.settings.psk_file or null)
729 (l.settings.http_dir or null)
730 (l.settings.cafile or null)
731 (l.settings.capath or null)
732 (l.settings.certfile or null)
733 (l.settings.crlfile or null)
734 (l.settings.dhparamfile or null)
735 (l.settings.keyfile or null)
736 ]) cfg.listeners)
737 (lib.mapAttrsToList (_: b: [
738 (b.settings.bridge_cafile or null)
739 (b.settings.bridge_capath or null)
740 (b.settings.bridge_certfile or null)
741 (b.settings.bridge_keyfile or null)
742 ]) cfg.bridges)
743 ]
744 )
745 );
746 RemoveIPC = true;
747 RestrictAddressFamilies = [
748 "AF_UNIX"
749 "AF_INET"
750 "AF_INET6"
751 "AF_NETLINK"
752 ];
753 RestrictNamespaces = true;
754 RestrictRealtime = true;
755 RestrictSUIDSGID = true;
756 SystemCallArchitectures = "native";
757 SystemCallFilter = [
758 "@system-service"
759 "~@privileged"
760 "~@resources"
761 ];
762 UMask = "0077";
763 };
764 preStart = lib.concatStringsSep "\n" (
765 lib.imap0 (
766 idx: listener:
767 makePasswordFile (listenerScope idx) listener.users "${cfg.dataDir}/passwd-${toString idx}"
768 ) cfg.listeners
769 );
770 };
771
772 environment.etc = lib.listToAttrs (
773 lib.imap0 (idx: listener: {
774 name = "mosquitto/acl-${toString idx}.conf";
775 value = {
776 user = config.users.users.mosquitto.name;
777 group = config.users.users.mosquitto.group;
778 mode = "0400";
779 text = (
780 lib.concatStringsSep "\n" (
781 lib.flatten [
782 listener.acl
783 (lib.mapAttrsToList (n: u: [ "user ${n}" ] ++ map (t: "topic ${t}") u.acl) listener.users)
784 ]
785 )
786 );
787 };
788 }) cfg.listeners
789 );
790
791 users.users.mosquitto = {
792 description = "Mosquitto MQTT Broker Daemon owner";
793 group = "mosquitto";
794 uid = config.ids.uids.mosquitto;
795 home = cfg.dataDir;
796 createHome = true;
797 };
798
799 users.groups.mosquitto.gid = config.ids.gids.mosquitto;
800
801 };
802
803 meta = {
804 maintainers = [ ];
805 doc = ./mosquitto.md;
806 };
807}