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}