nixpkgs-suyu/nixos/modules/services/networking/mosquitto.nix
pennae 56d0b5cd6a nixos/mosquitto: rewrite the module
mosquitto needs a lot of attention concerning its config because it doesn't
parse it very well, often ignoring trailing parts of lines, duplicated config
keys, or just looking back way further in the file to associated config keys
with previously defined items than might be expected.

this replaces the mosquitto module completely. we now have a hierarchical config
that flattens out to the mosquitto format (hopefully) without introducing spooky
action at a distance.
2021-10-22 16:06:55 -04:00

649 lines
18 KiB
Nix

{ config, lib, pkgs, ...}:
with lib;
let
cfg = config.services.mosquitto;
# note that mosquitto config parsing is very simplistic as of may 2021.
# often times they'll e.g. strtok() a line, check the first two tokens, and ignore the rest.
# there's no escaping available either, so we have to prevent any being necessary.
str = types.strMatching "[^\r\n]*" // {
description = "single-line string";
};
path = types.addCheck types.path (p: str.check "${p}");
configKey = types.strMatching "[^\r\n\t ]+";
optionType = with types; oneOf [ str path bool int ] // {
description = "string, path, bool, or integer";
};
optionToString = v:
if isBool v then boolToString v
else if path.check v then "${v}"
else toString v;
assertKeysValid = prefix: valid: config:
mapAttrsToList
(n: _: {
assertion = valid ? ${n};
message = "Invalid config key ${prefix}.${n}.";
})
config;
formatFreeform = { prefix ? "" }: mapAttrsToList (n: v: "${prefix}${n} ${optionToString v}");
userOptions = with types; submodule {
options = {
password = mkOption {
type = uniq (nullOr str);
default = null;
description = ''
Specifies the (clear text) password for the MQTT User.
'';
};
passwordFile = mkOption {
type = uniq (nullOr types.path);
example = "/path/to/file";
default = null;
description = ''
Specifies the path to a file containing the
clear text password for the MQTT user.
'';
};
hashedPassword = mkOption {
type = uniq (nullOr str);
default = null;
description = ''
Specifies the hashed password for the MQTT User.
To generate hashed password install <literal>mosquitto</literal>
package and use <literal>mosquitto_passwd</literal>.
'';
};
hashedPasswordFile = mkOption {
type = uniq (nullOr types.path);
example = "/path/to/file";
default = null;
description = ''
Specifies the path to a file containing the
hashed password for the MQTT user.
To generate hashed password install <literal>mosquitto</literal>
package and use <literal>mosquitto_passwd</literal>.
'';
};
acl = mkOption {
type = listOf str;
example = [ "read A/B" "readwrite A/#" ];
default = [];
description = ''
Control client access to topics on the broker.
'';
};
};
};
userAsserts = prefix: users:
mapAttrsToList
(n: _: {
assertion = builtins.match "[^:\r\n]+" n != null;
message = "Invalid user name ${n} in ${prefix}";
})
users
++ mapAttrsToList
(n: u: {
assertion = count (s: s != null) [
u.password u.passwordFile u.hashedPassword u.hashedPasswordFile
] <= 1;
message = "Cannot set more than one password option for user ${n} in ${prefix}";
}) users;
makePasswordFile = users: path:
let
makeLines = store: file:
mapAttrsToList
(n: u: "addLine ${escapeShellArg n} ${escapeShellArg u.${store}}")
(filterAttrs (_: u: u.${store} != null) users)
++ mapAttrsToList
(n: u: "addFile ${escapeShellArg n} ${escapeShellArg "${u.${file}}"}")
(filterAttrs (_: u: u.${file} != null) users);
plainLines = makeLines "password" "passwordFile";
hashedLines = makeLines "hashedPassword" "hashedPasswordFile";
in
pkgs.writeScript "make-mosquitto-passwd"
(''
#! ${pkgs.runtimeShell}
set -eu
file=${escapeShellArg path}
rm -f "$file"
touch "$file"
addLine() {
echo "$1:$2" >> "$file"
}
addFile() {
if [ $(wc -l <"$2") -gt 1 ]; then
echo "invalid mosquitto password file $2" >&2
return 1
fi
echo "$1:$(cat "$2")" >> "$file"
}
''
+ concatStringsSep "\n"
(plainLines
++ optional (plainLines != []) ''
${pkgs.mosquitto}/bin/mosquitto_passwd -U "$file"
''
++ hashedLines));
makeACLFile = idx: users: supplement:
pkgs.writeText "mosquitto-acl-${toString idx}.conf"
(concatStringsSep
"\n"
(flatten [
supplement
(mapAttrsToList
(n: u: [ "user ${n}" ] ++ map (t: "topic ${t}") u.acl)
users)
]));
authPluginOptions = with types; submodule {
options = {
plugin = mkOption {
type = path;
description = ''
Plugin path to load, should be a <literal>.so</literal> file.
'';
};
denySpecialChars = mkOption {
type = bool;
description = ''
Automatically disallow all clients using <literal>#</literal>
or <literal>+</literal> in their name/id.
'';
default = true;
};
options = mkOption {
type = attrsOf optionType;
description = ''
Options for the auth plugin. Each key turns into a <literal>auth_opt_*</literal>
line in the config.
'';
default = {};
};
};
};
authAsserts = prefix: auth:
mapAttrsToList
(n: _: {
assertion = configKey.check n;
message = "Invalid auth plugin key ${prefix}.${n}";
})
auth;
formatAuthPlugin = plugin:
[
"auth_plugin ${plugin.plugin}"
"auth_plugin_deny_special_chars ${optionToString plugin.denySpecialChars}"
]
++ formatFreeform { prefix = "auth_opt_"; } plugin.options;
freeformListenerKeys = {
allow_anonymous = 1;
allow_zero_length_clientid = 1;
auto_id_prefix = 1;
cafile = 1;
capath = 1;
certfile = 1;
ciphers = 1;
"ciphers_tls1.3" = 1;
crlfile = 1;
dhparamfile = 1;
http_dir = 1;
keyfile = 1;
max_connections = 1;
max_qos = 1;
max_topic_alias = 1;
mount_point = 1;
protocol = 1;
psk_file = 1;
psk_hint = 1;
require_certificate = 1;
socket_domain = 1;
tls_engine = 1;
tls_engine_kpass_sha1 = 1;
tls_keyform = 1;
tls_version = 1;
use_identity_as_username = 1;
use_subject_as_username = 1;
use_username_as_clientid = 1;
};
listenerOptions = with types; submodule {
options = {
port = mkOption {
type = port;
description = ''
Port to listen on. Must be set to 0 to listen on a unix domain socket.
'';
default = 1883;
};
address = mkOption {
type = nullOr str;
description = ''
Address to listen on. Listen on <literal>0.0.0.0</literal>/<literal>::</literal>
when unset.
'';
default = null;
};
authPlugins = mkOption {
type = listOf authPluginOptions;
description = ''
Authentication plugin to attach to this listener.
Refer to the <link xlink:href="https://mosquitto.org/man/mosquitto-conf-5.html">
mosquitto.conf documentation</link> for details on authentication plugins.
'';
default = [];
};
users = mkOption {
type = attrsOf userOptions;
example = { john = { password = "123456"; acl = [ "topic readwrite john/#" ]; }; };
description = ''
A set of users and their passwords and ACLs.
'';
default = {};
};
acl = mkOption {
type = listOf str;
description = ''
Additional ACL items to prepend to the generated ACL file.
'';
default = [];
};
settings = mkOption {
type = submodule {
freeformType = attrsOf optionType;
};
description = ''
Additional settings for this listener.
'';
default = {};
};
};
};
listenerAsserts = prefix: listener:
assertKeysValid prefix freeformListenerKeys listener.settings
++ userAsserts prefix listener.users
++ imap0
(i: v: authAsserts "${prefix}.authPlugins.${toString i}" v)
listener.authPlugins;
formatListener = idx: listener:
[
"listener ${toString listener.port} ${toString listener.address}"
"password_file ${cfg.dataDir}/passwd-${toString idx}"
"acl_file ${makeACLFile idx listener.users listener.acl}"
]
++ formatFreeform {} listener.settings
++ concatMap formatAuthPlugin listener.authPlugins;
freeformBridgeKeys = {
bridge_alpn = 1;
bridge_attempt_unsubscribe = 1;
bridge_bind_address = 1;
bridge_cafile = 1;
bridge_capath = 1;
bridge_certfile = 1;
bridge_identity = 1;
bridge_insecure = 1;
bridge_keyfile = 1;
bridge_max_packet_size = 1;
bridge_outgoing_retain = 1;
bridge_protocol_version = 1;
bridge_psk = 1;
bridge_require_ocsp = 1;
bridge_tls_version = 1;
cleansession = 1;
idle_timeout = 1;
keepalive_interval = 1;
local_cleansession = 1;
local_clientid = 1;
local_password = 1;
local_username = 1;
notification_topic = 1;
notifications = 1;
notifications_local_only = 1;
remote_clientid = 1;
remote_password = 1;
remote_username = 1;
restart_timeout = 1;
round_robin = 1;
start_type = 1;
threshold = 1;
try_private = 1;
};
bridgeOptions = with types; submodule {
options = {
addresses = mkOption {
type = listOf (submodule {
options = {
address = mkOption {
type = str;
description = ''
Address of the remote MQTT broker.
'';
};
port = mkOption {
type = port;
description = ''
Port of the remote MQTT broker.
'';
default = 1883;
};
};
});
default = [];
description = ''
Remote endpoints for the bridge.
'';
};
topics = mkOption {
type = listOf str;
description = ''
Topic patterns to be shared between the two brokers.
Refer to the <link xlink:href="https://mosquitto.org/man/mosquitto-conf-5.html">
mosquitto.conf documentation</link> for details on the format.
'';
default = [];
example = [ "# both 2 local/topic/ remote/topic/" ];
};
settings = mkOption {
type = submodule {
freeformType = attrsOf optionType;
};
description = ''
Additional settings for this bridge.
'';
default = {};
};
};
};
bridgeAsserts = prefix: bridge:
assertKeysValid prefix freeformBridgeKeys bridge.settings
++ [ {
assertion = length bridge.addresses > 0;
message = "Bridge ${prefix} needs remote broker addresses";
} ];
formatBridge = name: bridge:
[
"connection ${name}"
"addresses ${concatMapStringsSep " " (a: "${a.address}:${toString a.port}") bridge.addresses}"
]
++ map (t: "topic ${t}") bridge.topics
++ formatFreeform {} bridge.settings;
freeformGlobalKeys = {
allow_duplicate_messages = 1;
autosave_interval = 1;
autosave_on_changes = 1;
check_retain_source = 1;
connection_messages = 1;
log_facility = 1;
log_timestamp = 1;
log_timestamp_format = 1;
max_inflight_bytes = 1;
max_inflight_messages = 1;
max_keepalive = 1;
max_packet_size = 1;
max_queued_bytes = 1;
max_queued_messages = 1;
memory_limit = 1;
message_size_limit = 1;
persistence_file = 1;
persistence_location = 1;
persistent_client_expiration = 1;
pid_file = 1;
queue_qos0_messages = 1;
retain_available = 1;
set_tcp_nodelay = 1;
sys_interval = 1;
upgrade_outgoing_qos = 1;
websockets_headers_size = 1;
websockets_log_level = 1;
};
globalOptions = with types; {
enable = mkEnableOption "the MQTT Mosquitto broker";
bridges = mkOption {
type = attrsOf bridgeOptions;
default = {};
description = ''
Bridges to build to other MQTT brokers.
'';
};
listeners = mkOption {
type = listOf listenerOptions;
default = {};
description = ''
Listeners to configure on this broker.
'';
};
includeDirs = mkOption {
type = listOf path;
description = ''
Directories to be scanned for further config files to include.
Directories will processed in the order given,
<literal>*.conf</literal> files in the directory will be
read in case-sensistive alphabetical order.
'';
default = [];
};
logDest = mkOption {
type = listOf (either path (enum [ "stdout" "stderr" "syslog" "topic" "dlt" ]));
description = ''
Destinations to send log messages to.
'';
default = [ "stderr" ];
};
logType = mkOption {
type = listOf (enum [ "debug" "error" "warning" "notice" "information"
"subscribe" "unsubscribe" "websockets" "none" "all" ]);
description = ''
Types of messages to log.
'';
default = [];
};
persistence = mkOption {
type = bool;
description = ''
Enable persistent storage of subscriptions and messages.
'';
default = true;
};
dataDir = mkOption {
default = "/var/lib/mosquitto";
type = types.path;
description = ''
The data directory.
'';
};
settings = mkOption {
type = submodule {
freeformType = attrsOf optionType;
};
description = ''
Global configuration options for the mosquitto broker.
'';
default = {};
};
};
globalAsserts = prefix: cfg:
flatten [
(assertKeysValid prefix freeformGlobalKeys cfg.settings)
(imap0 (n: l: listenerAsserts "${prefix}.listener.${toString n}" l) cfg.listeners)
(mapAttrsToList (n: b: bridgeAsserts "${prefix}.bridge.${n}" b) cfg.bridges)
];
formatGlobal = cfg:
[
"per_listener_settings true"
"persistence ${optionToString cfg.persistence}"
]
++ map
(d: if path.check d then "log_dest file ${d}" else "log_dest ${d}")
cfg.logDest
++ map (t: "log_type ${t}") cfg.logType
++ formatFreeform {} cfg.settings
++ concatLists (imap0 formatListener cfg.listeners)
++ concatLists (mapAttrsToList formatBridge cfg.bridges)
++ map (d: "include_dir ${d}") cfg.includeDirs;
configFile = pkgs.writeText "mosquitto.conf"
(concatStringsSep "\n" (formatGlobal cfg));
in
{
###### Interface
options.services.mosquitto = globalOptions;
###### Implementation
config = mkIf cfg.enable {
assertions = globalAsserts "services.mosquitto" cfg;
systemd.services.mosquitto = {
description = "Mosquitto MQTT Broker Daemon";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
Type = "notify";
NotifyAccess = "main";
User = "mosquitto";
Group = "mosquitto";
RuntimeDirectory = "mosquitto";
WorkingDirectory = cfg.dataDir;
Restart = "on-failure";
ExecStart = "${pkgs.mosquitto}/bin/mosquitto -c ${configFile}";
ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
# Hardening
CapabilityBoundingSet = "";
DevicePolicy = "closed";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProcSubset = "pid";
ProtectSystem = "strict";
ReadWritePaths = [
cfg.dataDir
"/tmp" # mosquitto_passwd creates files in /tmp before moving them
] ++ filter path.check cfg.logDest;
ReadOnlyPaths =
map (p: "${p}")
(cfg.includeDirs
++ filter
(v: v != null)
(flatten [
(map
(l: [
(l.settings.psk_file or null)
(l.settings.http_dir or null)
(l.settings.cafile or null)
(l.settings.capath or null)
(l.settings.certfile or null)
(l.settings.crlfile or null)
(l.settings.dhparamfile or null)
(l.settings.keyfile or null)
])
cfg.listeners)
(mapAttrsToList
(_: b: [
(b.settings.bridge_cafile or null)
(b.settings.bridge_capath or null)
(b.settings.bridge_certfile or null)
(b.settings.bridge_keyfile or null)
])
cfg.bridges)
]));
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_UNIX" # for sd_notify() call
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
"~@resources"
];
UMask = "0077";
};
preStart =
concatStringsSep
"\n"
(imap0
(idx: listener: makePasswordFile listener.users "${cfg.dataDir}/passwd-${toString idx}")
cfg.listeners);
};
users.users.mosquitto = {
description = "Mosquitto MQTT Broker Daemon owner";
group = "mosquitto";
uid = config.ids.uids.mosquitto;
home = cfg.dataDir;
createHome = true;
};
users.groups.mosquitto.gid = config.ids.gids.mosquitto;
};
meta.maintainers = with lib.maintainers; [ pennae ];
}