nixConfig/nixos/modules/mail.nix

313 lines
13 KiB
Nix
Raw Normal View History

/*
* Building: For some reason, stalwart is not served by cache.nixos.org and thus needs to be built locally.
* Be aware that this needs some hours, about 12Gb RAM and a few Gb free space in /tmp.
* Forwarding mails: Update the Sops-secrets in the machine directory, rebuild and deploy.
* Everything else should happen automatically but new redirects might take up to two hours due HRZ infrastructure.
* Using the web admin interface: Set your SSH to do portforwarding of some local port to port 80 of the VM and
* and use your personal admin account or create one using the fallback admin password.
* Create users with mail boxes: Go to the admin interface and create them.
* Stalwart mailserver docs can be found at https://stalw.art/docs
* DNS-Records: Collect the right DNS entries from the management interface and copy them to the DNS hoster. Caution:
* Not all entries are applicable since we relay via HRZ.
*/
{
config,
lib,
pkgs,
...
}: let
inherit
(lib)
mkIf
mkEnableOption
mkOption
;
2024-12-14 16:31:31 +00:00
inherit (lib.types) listOf strMatching str path;
cfg = config.services.mathebau-mail;
in {
options.services.mathebau-mail = {
enable = mkEnableOption "mathebau mail service";
2024-12-14 16:31:31 +00:00
stalwartAdmin = mkOption {
type = path;
description = "Path to a file that contains the stalwart fallback admin password encoded for HTTP Basic Auth";
};
stalwartAdminHash = mkOption {
type = str;
description = "String containing the hashed fallback admin password";
};
domains = mkOption {
type = listOf (lib.types.submodule {
options = {
domain = mkOption {
2024-12-14 16:31:31 +00:00
description = "Domain name that we serve. We also push its addresses to HRZ.";
type = strMatching "^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$"; #Regex from https://www.oreilly.com/library/view/regular-expressions-cookbook/9781449327453/ch08s15.html
};
allowlistPass = mkOption {
2024-12-14 16:31:31 +00:00
description = "Password file for the HRZ API that gets a list of mailaddresses that we serve";
type = path;
};
virt_aliases = mkOption {
2024-12-14 16:31:31 +00:00
description = "File path to a virtual alias file applicable for this domain";
type = path;
default = "/dev/null"; # there might not be an alias file and reading an empty one works with our implementation
};
};
});
};
};
config = mkIf cfg.enable {
services = {
stalwart-mail = {
enable = true;
openFirewall = true;
settings = {
server = {
lookup.default.hostname = "fb04184.mathematik.tu-darmstadt.de"; # Because the DNS PTR of 130.83.2.184 is this and this should be used in SMTP EHLO.
listener = {
2024-12-14 16:31:31 +00:00
# Do not enable JMAP until https://github.com/stalwartlabs/mail-server/issues/618 is resolved!
# Luckily, this bug does not apply to IMAP.
"smtp" = {
bind = ["[::]:25"];
protocol = "smtp";
};
"submissions" = {
bind = ["[::]:465"];
protocol = "smtp";
tls.implicit = true;
};
"imaptls" = {
bind = ["[::]:993"];
protocol = "imap";
tls.implicit = true;
};
"management" = {
2024-12-14 16:31:31 +00:00
# Cthulhu forwards requests for http://fb04184.mathematik.tu-darmstadt.de/.well-known/acme-challenge/ http://imap.mathebau.de/.well-known/acme-challenge/ and http://smtp.mathebau.de/.well-known/acme-challenge/
# for TLS certificate challenge validation
# whereas the rest of the management interface is not available publically.
# It can be reached via SSH and portforwarding.
bind = ["[::]:80"];
protocol = "http";
};
};
};
acme.letsencrypt = {
directory = "https://acme-v02.api.letsencrypt.org/directory"; # This setting is necessary for this block to be activated
challenge = "http-01";
contact = ["root@mathebau.de"];
domains = ["fb04184.mathematik.tu-darmstadt.de" "imap.mathebau.de" "smtp.mathebau.de"];
default = true;
};
spam.header.is-spam = "Dummyheader"; # disable moving to spam which would conflict with forwarding
auth = {
# TODO check if HRZ conforms to these standards and we can validate them strictly
dkim.verify = "relaxed";
arc.verify = "relaxed";
dmarc.verify = "relaxed";
iprev.verify = "relaxed";
spf.verify.ehlo = "relaxed";
spf.verify.mail-from = "relaxed";
};
# Forward outgoing mail to HRZ or mail VMs.
# see https://stalw.art/docs/smtp/outbound/routing/ relay host example
queue.outbound = {
next-hop = [
{
"if" = "rcpt_domain = 'lists.mathebau.de'";
"then" = "'mailman'";
}
{
"if" = "is_local_domain('', rcpt_domain)";
"then" = "'local'";
}
{"else" = "'hrz'";}
];
tls = {
2024-12-14 16:31:31 +00:00
# we only talk to HRZ and our own VMs anyway
mta-sts = "disable";
dane = "disable";
starttls = "optional"; # e.g. Lobon does not offer starttls
};
};
remote."hrz" = {
address = "mailout.hrz.tu-darmstadt.de";
port = 25;
protocol = "smtp";
2024-12-14 16:31:31 +00:00
tls.implicit = false; # Don't assume TLS on this port but use STARTTLS
};
remote."mailman" = {
address = "lobon.mathebau.de"; # must be created in DNS as a MX record because this field does not accept ip addresses.
port = 25;
protocol = "smtp";
2024-12-14 16:31:31 +00:00
tls.implicit = false; # Don't assume TLS on this port but use STARTTLS
};
session.rcpt = {
# In order to accept mail that we only forward
# without having to generate an account.
# Invalid addresses are filtered by DFN beforehand.
catch-all = true;
relay = [
{
"if" = "!is_empty(authenticated_as) || rcpt_domain == 'lists.mathebau.de'";
"then" = true;
}
{"else" = false;}
];
};
2024-12-14 16:31:31 +00:00
# Stalwart gets its configuration from two places: A TOML configuration file that we control in this module
# and from a database that can be configured from web management interface or via Rest API.
# We here define what comes from the TOML-file and especially add "sieve.trusted.scripts.*" to the default ones
# because only TOML-based keys may use macros to load files from disk.
# We want this to be able to load our sieve-script for mail forwarding.
config.local-keys =
[
"store.*"
"directory.*"
"tracer.*"
"server.*"
"!server.blocked-ip.*"
"authentication.fallback-admin.*"
"cluster.node-id"
"storage.data"
"storage.blob"
"storage.lookup"
"storage.fts"
"storage.directory"
"lookup.default.hostname"
"certificate.*"
] # the default ones
++ ["sieve.trusted.scripts.*"]; #for macros to be able to include our redirection script
sieve.trusted.scripts.redirects.contents = "%{file:/tmp/virt_aliases}%"; # generated redirect script
session.data.script = "'redirects'";
authentication.fallback-admin = {
user = "admin";
2024-12-14 16:31:31 +00:00
# see passwd on azathoth for plaintext or machine secret in encoded format for HTTP Basic AUTH
secret = cfg.stalwartAdminHash;
};
};
};
};
environment.persistence.${config.impermanence.name} = {
directories = [
"/var/lib/stalwart-mail"
];
files = ["/root/.ssh/known_hosts"]; # for the backup server bragi
};
# Update HRZ allowlist
# For account details see https://www-cgi.hrz.tu-darmstadt.de/mail/
# will stop working if no valid TUIDs are associated to our domain.
systemd = {
timers."mailAllowlist" = {
wantedBy = ["timers.target"];
timerConfig = {
OnBootSec = "1h"; # Run every hour
OnUnitActiveSec = "1h";
RandomizedDelaySec = "10m"; # prevent overload on regular intervals
Unit = "mailAllowlist.service";
};
};
services = {
"mailAllowlist" = {
description = "Allowlist update: Post the mail addresses to the HRZ allowllist";
script = let
scriptTemplate = {
domain,
allowlistPass,
...
}: ''
echo "process ${domain}"
2024-12-14 16:31:31 +00:00
# This line gets the available mailboxes from stalwart's Rest API, searches for their addresses and collects them to a file for submission.
${pkgs.curl}/bin/curl -s --header "authorization: Basic $(<${cfg.stalwartAdmin})" http://localhost/api/principal | ${pkgs.gnugrep}/bin/grep -o -e "[A-Za-z0-9.!#\$%&'*+-/=?^_{|}~]*@${domain}" | tee /tmp/addresses
# This line searches for available redirects and adds them to the submission file.
${pkgs.gnugrep}/bin/grep -o -e "[A-Za-z0-9.!#\$%&'*+-/=?^_{|}~]*@${domain}" /tmp/virt_aliases >> /tmp/addresses # This doesn't catch all RFC conform local parts. Improve if you need.
2024-12-14 16:31:31 +00:00
# Post local-parts to HRZ, see https://www-cgi.hrz.tu-darmstadt.de/mail/index.php?bereich=whitelist_upload
${pkgs.curl}/bin/curl -s https://www-cgi.hrz.tu-darmstadt.de/mail/whitelist-update.php -F emaildomain=${domain} -F password=$(cat ${allowlistPass}) -F emailliste=@/tmp/addresses -F meldungen=voll
2024-12-14 16:31:31 +00:00
# Cleanup submission file
rm /tmp/addresses
'';
in
lib.strings.concatStringsSep "" (map scriptTemplate cfg.domains);
serviceConfig = {
Type = "oneshot";
User = "stalwart-mail";
NoNewPrivileges = true;
# See https://www.man7.org/linux/man-pages/man5/systemd.exec.5.html
PrivateTmp = false; # allow access to sieve script
ProtectHome = true;
ReadOnlyPaths = "/";
ReadWritePaths = "/tmp";
InaccessiblePaths = "-/lost+found";
PrivateDevices = true;
PrivateUsers = true;
ProtectHostname = true;
ProtectClock = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
};
};
"stalwart-mail" = {
restartTriggers = lib.attrsets.mapAttrsToList (_: aliaslist: aliaslist.sopsFile) config.sops.secrets; # restart if secrets, especially alias files, have changed.
serviceConfig.PrivateTmp = lib.mkForce false; # enable access to generated Sieve script
};
"virt-aliases-generator" = {
description = "Virtual Aliases Generator: Generate a sieve script from the virtual alias file";
2024-12-14 16:31:31 +00:00
script = lib.strings.concatStringsSep "" (["${pkgs.alias-to-sieve}/bin/alias_to_sieve "] ++ map (x: "${x.virt_aliases} ${x.domain} ") cfg.domains ++ ["> /tmp/virt_aliases"]);
wantedBy = ["stalwart-mail.service"]; # Rerun on stalwart restart because forwardings may have changed.
serviceConfig = {
Type = "oneshot";
User = "stalwart-mail";
NoNewPrivileges = true;
# See https://www.man7.org/linux/man-pages/man5/systemd.exec.5.html
PrivateTmp = false;
ProtectHome = true;
ReadOnlyPaths = "/";
ReadWritePaths = "/tmp";
InaccessiblePaths = "-/lost+found";
PrivateDevices = true;
PrivateUsers = true;
ProtectHostname = true;
ProtectClock = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
};
};
};
};
# Backups
services.borgbackup.jobs.mail = {
paths = [
"/var/lib/stalwart-mail/data"
];
encryption.mode = "none"; # Otherwise the key is next to the backup or we have human interaction.
environment = {
BORG_RSH = "ssh -i /run/secrets/backupKey";
# “Borg ensures that backups are not created on random drives that just happen to contain a Borg repository.”
# https://borgbackup.readthedocs.io/en/stable/deployment/automated-local.html
# We don't want this in order to not need to persist borg cache and simplify new deployments.
BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK = "yes";
};
repo = "borg@192.168.1.11:kaluut"; # TODO for https://gitea.mathebau.de/Fachschaft/nixConfig/issues/33
startAt = "daily";
user = "root";
group = "root";
};
};
}