blob: 4595c21418ada41d41accf3731e8c426a1e84cb1 (
plain) (
tree)
|
|
{
lib,
config,
pkgs,
vhackPackages,
...
}: let
cfg = config.vhack.stalwart-mail;
topCfg = config.services.stalwart-mail;
configFormat = pkgs.formats.toml {};
configFile = configFormat.generate "stalwart-mail.toml" topCfg.settings;
in {
imports = [
./settings.nix
];
options.vhack.stalwart-mail = {
enable = lib.mkEnableOption "starwart-mail";
package = lib.mkPackageOption vhackPackages "stalwart-mail-free" {};
fqdn = lib.mkOption {
type = lib.types.str;
example = "mail.foss-syndicate.org";
description = ''
The fully qualified domain name for this mail server.
'';
};
principals = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Specifies the username of the account";
};
class = lib.mkOption {
type = lib.types.enum ["individual" "admin"];
description = "Specifies the account type";
};
description = lib.mkOption {
type = lib.types.str;
description = "Provides a description or full name for the user";
default = "";
};
secret = lib.mkOption {
type = lib.types.str;
description = ''
Sets the password for the user account.
Passwords can be stored hashed or in plain text (not recommended).
See <https://stalw.art/docs/auth/authentication/password/> for a description
of password encoding.
'';
};
email = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
A list of email addresses associated with the user.
The first address in the list is considered the primary address.
'';
};
};
});
};
dataDirectory = lib.mkOption {
description = ''
The directory in which to store all storage things.
'';
default = "/var/lib/stalwart-mail";
type = lib.types.path;
readOnly = true;
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to open TCP firewall ports, which are specified in
{option}`services.stalwart-mail.settings.listener` on all interfaces.
'';
};
security = lib.mkOption {
type = lib.types.nullOr (lib.types.submodule {
options = {
dkimPrivateKeyPath = lib.mkOption {
type = lib.types.path;
description = ''
The path to the dkim private key agenix file.
'';
};
};
});
description = ''
Security options. This should only be set to `null` when testing.
'';
};
};
config = lib.mkIf cfg.enable {
services.stalwart-mail = {
# NOTE(@bpeetz): We do not use the nixos service, as it comes with too much
# bothersome default configuration and not really any useful config.
# However, this decision could obviously be reverse in the future. <2025-02-08>
enable = false;
inherit (cfg) package;
# dataDir = cfg.dataDirectory;
};
security.acme.certs = {
"${cfg.fqdn}" = {
domain = cfg.fqdn;
group = "stalwart-mail";
};
};
age.secrets = lib.mkIf (cfg.security != null) {
stalwartMailDkim = {
file = cfg.security.dkimPrivateKeyPath;
mode = "600";
owner = "stalwart-mail";
group = "stalwart-mail";
};
};
vhack.persist.directories = [
{
directory = "${cfg.dataDirectory}";
user = "stalwart-mail";
group = "stalwart-mail";
mode = "0700";
}
{
directory = "${config.services.redis.servers."stalwart-mail".settings.dir}";
user = "stalwart-mail";
group = "redis";
mode = "0770";
}
];
services.redis = {
servers = {
"stalwart-mail" = {
enable = true;
user = "stalwart-mail";
# Disable TCP listening. (We have a UNIX socket)
port = 0;
bind = null;
settings = {
protected-mode = true;
enable-protected-configs = false;
enable-debug-command = false;
enable-module-command = false;
supervised = "systemd";
stop-writes-on-bgsave-error = true;
sanitize-dump-payload = "clients";
};
};
};
};
# This service stores a potentially large amount of data.
# Running it as a dynamic user would force chown to be run every time the
# service is restarted on a potentially large number of files.
# That would cause unnecessary and unwanted delays.
users = {
groups.stalwart-mail = {};
users.stalwart-mail = {
isSystemUser = true;
group = "stalwart-mail";
};
};
systemd.tmpfiles.rules = [
"d '${cfg.dataDirectory}' - stalwart-mail stalwart-mail - -"
];
systemd = {
packages = [cfg.package];
services.stalwart-mail = {
wantedBy = ["multi-user.target"];
after = [
"local-fs.target"
"network.target"
];
preStart = let
esa = lib.strings.escapeShellArg;
mkTmpFile = path: "[ -d ${esa path} ] || mkdir --parents ${esa path}";
# Create the directories for stalwart
storageDirectories = lib.lists.filter (v: v != null) (lib.attrsets.mapAttrsToList (_: {path ? null, ...}:
if (path != null)
then mkTmpFile path
else null)
topCfg.settings.store);
in
''
# Stalwart actually wants to store _data_ (e.g., blocked ips) in it's own config file.
# Thus we need to make it writable.
cat ${esa configFile} >$CACHE_DIRECTORY/mutable_config_file.toml
''
+ (builtins.concatStringsSep "\n" storageDirectories);
serviceConfig = {
ExecStart = [
""
"${cfg.package}/bin/stalwart-mail --config=$CACHE_DIRECTORY/mutable_config_file.toml"
];
StandardOutput = "journal";
StandardError = "journal";
ReadWritePaths = [
cfg.dataDirectory
];
CacheDirectory = "stalwart-mail";
StateDirectory = "stalwart-mail";
# Bind standard privileged ports
AmbientCapabilities = ["CAP_NET_BIND_SERVICE"];
CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"];
# Hardening
DeviceAllow = [""];
LockPersonality = true;
MemoryDenyWriteExecute = true;
PrivateDevices = true;
PrivateUsers = false; # incompatible with CAP_NET_BIND_SERVICE
ProcSubset = "pid";
PrivateTmp = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_UNIX"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
];
UMask = "0077";
};
unitConfig.ConditionPathExists = [
""
"${configFile}"
];
};
};
# Make admin commands available in the shell
environment.systemPackages = [cfg.package];
networking.firewall = let
parsePorts = listeners: let
parseAddresses = listeners: lib.flatten (lib.mapAttrsToList (name: value: value.bind) listeners);
splitAddress = addr: lib.splitString ":" addr;
extractPort = addr: lib.toInt (builtins.foldl' (a: b: b) "" (splitAddress addr));
in
builtins.map extractPort (parseAddresses listeners);
in
lib.mkIf (cfg.openFirewall && (builtins.hasAttr "listener" topCfg.settings.server))
{
allowedTCPPorts = parsePorts topCfg.settings.server.listener;
};
};
}
|