300 lines
12 KiB
Nix
300 lines
12 KiB
Nix
|
/*
|
|||
|
* 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
|
|||
|
*/
|
|||
|
{
|
|||
|
config,
|
|||
|
lib,
|
|||
|
pkgs,
|
|||
|
...
|
|||
|
}: let
|
|||
|
inherit
|
|||
|
(lib)
|
|||
|
mkIf
|
|||
|
mkEnableOption
|
|||
|
mkOption
|
|||
|
;
|
|||
|
inherit (lib.types) listOf str;
|
|||
|
cfg = config.services.mathebau-mail;
|
|||
|
in {
|
|||
|
options.services.mathebau-mail = {
|
|||
|
enable = mkEnableOption "mathebau mail service";
|
|||
|
domains = mkOption {
|
|||
|
type = listOf (lib.types.submodule {
|
|||
|
options = {
|
|||
|
domain = mkOption {
|
|||
|
type = str;
|
|||
|
};
|
|||
|
allowlistPass = mkOption {
|
|||
|
# Password for the HRZ API that gets a list of mailaddresses that we serve
|
|||
|
type = str;
|
|||
|
};
|
|||
|
virt_aliases = mkOption {
|
|||
|
type = str;
|
|||
|
default = "";
|
|||
|
};
|
|||
|
};
|
|||
|
});
|
|||
|
};
|
|||
|
};
|
|||
|
|
|||
|
config = mkIf cfg.enable {
|
|||
|
environment.systemPackages = [pkgs.alias-to-sieve]; # install converter from alias files to sieve scripts
|
|||
|
|
|||
|
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 = {
|
|||
|
"smtp" = {
|
|||
|
bind = ["[::]:25"];
|
|||
|
protocol = "smtp";
|
|||
|
};
|
|||
|
"submissions" = {
|
|||
|
# Enabling sending from these domains privately blocked on https://github.com/stalwartlabs/mail-server/issues/618
|
|||
|
bind = ["[::]:465"];
|
|||
|
protocol = "smtp";
|
|||
|
tls.implicit = true;
|
|||
|
};
|
|||
|
"imaptls" = {
|
|||
|
bind = ["[::]:993"];
|
|||
|
protocol = "imap";
|
|||
|
tls.implicit = true;
|
|||
|
};
|
|||
|
"management" = {
|
|||
|
bind = ["[::]:80"]; # This must also bind publically for ACME to work.
|
|||
|
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 = {
|
|||
|
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";
|
|||
|
tls.implicit = false; # somehow this is needed here
|
|||
|
};
|
|||
|
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";
|
|||
|
tls.implicit = false; # somehow this is needed here
|
|||
|
};
|
|||
|
|
|||
|
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;}
|
|||
|
];
|
|||
|
};
|
|||
|
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";
|
|||
|
secret = "$argon2i$v=19$m=4096,t=3,p=1$d0hYOTkzclpzSmFTZUplWnhVeWE$I7q9uB19RWL0oZKaPlMPSlGfFp6FQ/vrx80FFKCsalg"; # see machine secret for plaintext
|
|||
|
};
|
|||
|
tracer.stdout.level = "debug";
|
|||
|
};
|
|||
|
};
|
|||
|
};
|
|||
|
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}"
|
|||
|
# Get the mail addresses' local-part
|
|||
|
${pkgs.curl}/bin/curl -s --header "authorization: Basic $(</run/secrets/stalwartAdmin)" http://localhost/api/principal | ${pkgs.gnugrep}/bin/grep -o -e "[A-Za-z0-9.!#\$%&'*+-/=?^_{|}~]*@${domain}" | tee /tmp/addresses
|
|||
|
${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.
|
|||
|
# Post local-parts to HRZ
|
|||
|
${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
|
|||
|
# Cleanup
|
|||
|
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";
|
|||
|
script = let
|
|||
|
scriptTemplate = {
|
|||
|
domain,
|
|||
|
virt_aliases,
|
|||
|
...
|
|||
|
}:
|
|||
|
if virt_aliases != ""
|
|||
|
then "${virt_aliases} ${domain} "
|
|||
|
else "";
|
|||
|
in
|
|||
|
lib.strings.concatStringsSep "" (["${pkgs.alias-to-sieve}/bin/alias_to_sieve "] ++ map scriptTemplate 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";
|
|||
|
};
|
|||
|
};
|
|||
|
}
|