/* * 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 ; inherit (lib.types) listOf strMatching str path; cfg = config.services.mathebau-mail; in { options.services.mathebau-mail = { enable = mkEnableOption "mathebau mail service"; 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 { 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 { description = "Password file for the HRZ API that gets a list of mailaddresses that we serve"; type = path; }; virt_aliases = mkOption { 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 = { # 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" = { # 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 = { # 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"; 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"; 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;} ]; }; # 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"; # 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}" # 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. # 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 # 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"; 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"; }; }; }