blob: 6905005cd149e1fffcd6eca2d2573786a6265cdf (
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" {};
admin = lib.mkOption {
description = ''
Email address to advertise as administrator. This is the address, where dkim, spv
etc. refusal reports are sent to.
The format should be: `mailto:<name>@<domain>`
'';
type = lib.types.str;
example = "mailto:dmarc+rua@example.com";
default = "";
};
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 {
default = [];
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 = {
verificationMode = lib.mkOption {
type = lib.types.enum ["relaxed" "strict"];
description = ''
Whether to allow invalid signatures/checks or not.
'';
default = "relaxed";
};
dkimKeys = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
dkimPublicKey = lib.mkOption {
type = lib.types.str;
description = ''
The base 64 encoded representation of the public dkim key.
'';
};
dkimPrivateKeyPath = lib.mkOption {
type = lib.types.path;
description = ''
The path to the dkim private key agenix file.
Generate it via the `./gen_key` script:
'';
};
keyAlgorithm = lib.mkOption {
type = lib.types.enum ["ed25519-sha256" "rsa-sha-256" "rsa-sha-1"];
description = "The algorithm of the used key";
};
};
});
description = ''
Which key to use for which domain. The attr keys are the domains
'';
default = {};
};
};
});
description = ''
Security options. This should only be set to `null` when testing.
'';
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.admin != "";
message = "You need to specify an admin address.";
}
];
vhack.nginx.enable = true;
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 configuration.
# However, this decision could obviously be reversed in the future. <2025-02-08>
enable = false;
inherit (cfg) package;
# dataDir = cfg.dataDirectory;
};
# FIXME(@bpeetz): This is currently needed for a successful acme http-01 challenge.
# We could also use the DNS challenge. <2025-03-01>
nginx.virtualHosts."${cfg.fqdn}" = {
enableACME = false;
extraConfig =
# This is copied directly from the nixos nginx module.
# Rule for legitimate ACME Challenge requests (like /.well-known/acme-challenge/xxxxxxxxx)
# We use ^~ here, so that we don't check any regexes (which could
# otherwise easily override this intended match accidentally).
''
location ^~ /.well-known/acme-challenge/ {
root ${config.security.acme.certs.${cfg.fqdn}.webroot};
auth_basic off;
auth_request off;
}
'';
};
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";
};
};
};
};
};
security.acme.certs = {
"${cfg.fqdn}" = {
domain = cfg.fqdn;
group = "stalwart-mail";
};
};
age.secrets = let
keys =
lib.mapAttrs' (
keyDomain: keyConfig:
lib.nameValuePair "stalwartMail${keyDomain}"
{
file = keyConfig.dkimPrivateKeyPath;
mode = "600";
owner = "stalwart-mail";
group = "stalwart-mail";
}
)
cfg.security.dkimKeys;
in
lib.mkIf (cfg.security != null) keys;
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";
}
];
# 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 = {
services.stalwart-mail = {
wantedBy = ["multi-user.target"];
requires =
[
"redis-stalwart-mail.service"
"network-online.target"
]
++ (lib.optional (cfg.security != null) "acme-${cfg.fqdn}.service");
after = [
"local-fs.target"
"network.target"
"network-online.target"
"redis-stalwart-mail.service"
"acme-${cfg.fqdn}.service"
];
conflicts = [
"postfix.service"
"sendmail.service"
"exim4.service"
];
description = "Stalwart Mail Server";
environment = {
SSL_CERT_FILE = "/etc/ssl/certs/ca-certificates.crt";
NIX_SSL_CERT_FILE = "/etc/ssl/certs/ca-certificates.crt";
};
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 = pkgs.writers.writeDash "start-stalwart-mail" ''
${lib.getExe cfg.package} --config="$CACHE_DIRECTORY/mutable_config_file.toml"
'';
Restart = "on-failure";
RestartSec = 5;
KillMode = "process";
KillSignal = "SIGINT";
Type = "simple";
LimitNOFILE = 65536;
StandardOutput = "journal";
StandardError = "journal";
ReadWritePaths = [
cfg.dataDirectory
];
CacheDirectory = "stalwart-mail";
StateDirectory = "stalwart-mail";
User = "stalwart-mail";
Group = "stalwart-mail";
SyslogIdentifier = "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";
};
};
};
# 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;
};
};
}
|