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}