2024-07-10 20:56:46 +00:00
/*
* 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 1 2 Gb RAM and a few Gb free space in /tmp.
2024-12-14 16:31:31 +00:00
* If you only want to deploy configuration changes and no software updates , consider building on the target VM .
* It has stalwart in its nix store and does not need to rebuild it .
* Forwarding mails : Update the Sops-secrets in the machine directory , rebuild on the VM and deploy .
2024-07-10 20:56:46 +00:00
* 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 ;
2024-07-10 20:56:46 +00:00
cfg = config . services . mathebau-mail ;
in {
options . services . mathebau-mail = {
enable = mkEnableOption " m a t h e b a u m a i l s e r v i c e " ;
2024-12-14 16:31:31 +00:00
stalwartAdmin = mkOption {
type = path ;
description = " P a t h t o a f i l e t h a t c o n t a i n s t h e s t a l w a r t f a l l b a c k a d m i n p a s s w o r d e n c o d e d f o r H T T P B a s i c A u t h " ;
} ;
stalwartAdminHash = mkOption {
type = str ;
description = " S t r i n g c o n t a i n i n g t h e h a s h e d f a l l b a c k a d m i n p a s s w o r d " ;
} ;
2024-07-10 20:56:46 +00:00
domains = mkOption {
type = listOf ( lib . types . submodule {
options = {
domain = mkOption {
2024-12-14 16:31:31 +00:00
description = " D o m a i n n a m e t h a t w e s e r v e . W e a l s o p u s h i t s a d d r e s s e s t o H R Z . " ;
type = strMatching " ^ ( [ a - z 0 - 9 ] + ( - [ a - z 0 - 9 ] + ) * \. ) + [ a - z ] { 2 , } $ " ; #Regex from https://www.oreilly.com/library/view/regular-expressions-cookbook/9781449327453/ch08s15.html
2024-07-10 20:56:46 +00:00
} ;
allowlistPass = mkOption {
2024-12-14 16:31:31 +00:00
description = " P a s s w o r d f i l e f o r t h e H R Z A P I t h a t g e t s a l i s t o f m a i l a d d r e s s e s t h a t w e s e r v e " ;
type = path ;
2024-07-10 20:56:46 +00:00
} ;
virt_aliases = mkOption {
2024-12-14 16:31:31 +00:00
description = " F i l e p a t h t o a v i r t u a l a l i a s f i l e a p p l i c a b l e f o r t h i s d o m a i n " ;
type = path ;
default = " / d e v / n u l l " ; # there might not be an alias file and reading an empty one works with our implementation
2024-07-10 20:56:46 +00:00
} ;
} ;
} ) ;
} ;
} ;
config = mkIf cfg . enable {
services = {
stalwart-mail = {
enable = true ;
openFirewall = true ;
settings = {
server = {
lookup . default . hostname = " f b 0 4 1 8 4 . m a t h e m a t i k . t u - d a r m s t a d t . d e " ; # 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.
2024-07-10 20:56:46 +00:00
" s m t p " = {
bind = [ " [ : : ] : 2 5 " ] ;
protocol = " s m t p " ;
} ;
" s u b m i s s i o n s " = {
bind = [ " [ : : ] : 4 6 5 " ] ;
protocol = " s m t p " ;
tls . implicit = true ;
} ;
" i m a p t l s " = {
bind = [ " [ : : ] : 9 9 3 " ] ;
protocol = " i m a p " ;
tls . implicit = true ;
} ;
" m a n a g e m e n t " = {
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 = [ " [ : : ] : 8 0 " ] ;
2024-07-10 20:56:46 +00:00
protocol = " h t t p " ;
} ;
} ;
} ;
acme . letsencrypt = {
directory = " h t t p s : / / a c m e - v 0 2 . a p i . l e t s e n c r y p t . o r g / d i r e c t o r y " ; # This setting is necessary for this block to be activated
challenge = " h t t p - 0 1 " ;
contact = [ " r o o t @ m a t h e b a u . d e " ] ;
domains = [ " f b 0 4 1 8 4 . m a t h e m a t i k . t u - d a r m s t a d t . d e " " i m a p . m a t h e b a u . d e " " s m t p . m a t h e b a u . d e " ] ;
default = true ;
} ;
spam . header . is-spam = " D u m m y h e a d e r " ; # 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 = " r e l a x e d " ;
arc . verify = " r e l a x e d " ;
dmarc . verify = " r e l a x e d " ;
iprev . verify = " r e l a x e d " ;
spf . verify . ehlo = " r e l a x e d " ;
spf . verify . mail-from = " r e l a x e d " ;
} ;
# Forward outgoing mail to HRZ or mail VMs.
# see https://stalw.art/docs/smtp/outbound/routing/ relay host example
queue . outbound = {
next-hop = [
{
" i f " = " r c p t _ d o m a i n = ' l i s t s . m a t h e b a u . d e ' " ;
" t h e n " = " ' m a i l m a n ' " ;
}
{
" i f " = " i s _ l o c a l _ d o m a i n ( ' ' , r c p t _ d o m a i n ) " ;
" t h e n " = " ' l o c a l ' " ;
}
{ " e l s e " = " ' h r z ' " ; }
] ;
tls = {
2024-12-14 16:31:31 +00:00
# we only talk to HRZ and our own VMs anyway
2024-07-10 20:56:46 +00:00
mta-sts = " d i s a b l e " ;
dane = " d i s a b l e " ;
starttls = " o p t i o n a l " ; # e.g. Lobon does not offer starttls
} ;
} ;
remote . " h r z " = {
address = " m a i l o u t . h r z . t u - d a r m s t a d t . d e " ;
port = 25 ;
protocol = " s m t p " ;
2024-12-14 16:31:31 +00:00
tls . implicit = false ; # Don't assume TLS on this port but use STARTTLS
2024-07-10 20:56:46 +00:00
} ;
remote . " m a i l m a n " = {
address = " l o b o n . m a t h e b a u . d e " ; # must be created in DNS as a MX record because this field does not accept ip addresses.
port = 25 ;
protocol = " s m t p " ;
2024-12-14 16:31:31 +00:00
tls . implicit = false ; # Don't assume TLS on this port but use STARTTLS
2024-07-10 20:56:46 +00:00
} ;
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 = [
{
" i f " = " ! i s _ e m p t y ( a u t h e n t i c a t e d _ a s ) | | r c p t _ d o m a i n = = ' l i s t s . m a t h e b a u . d e ' " ;
" t h e n " = true ;
}
{ " e l s e " = 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.
2024-07-10 20:56:46 +00:00
config . local-keys =
[
" s t o r e . * "
" d i r e c t o r y . * "
" t r a c e r . * "
" s e r v e r . * "
" ! s e r v e r . b l o c k e d - i p . * "
" a u t h e n t i c a t i o n . f a l l b a c k - a d m i n . * "
" c l u s t e r . n o d e - i d "
" s t o r a g e . d a t a "
" s t o r a g e . b l o b "
" s t o r a g e . l o o k u p "
" s t o r a g e . f t s "
" s t o r a g e . d i r e c t o r y "
" l o o k u p . d e f a u l t . h o s t n a m e "
" c e r t i f i c a t e . * "
] # the default ones
++ [ " s i e v e . t r u s t e d . s c r i p t s . * " ] ; #for macros to be able to include our redirection script
sieve . trusted . scripts . redirects . contents = " % { f i l e : / t m p / v i r t _ a l i a s e s } % " ; # generated redirect script
session . data . script = " ' r e d i r e c t s ' " ;
authentication . fallback-admin = {
user = " a d m i n " ;
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 ;
2024-07-10 20:56:46 +00:00
} ;
} ;
} ;
} ;
environment . persistence . ${ config . impermanence . name } = {
directories = [
" / v a r / l i b / s t a l w a r t - m a i l "
] ;
files = [ " / r o o t / . s s h / k n o w n _ h o s t s " ] ; # 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 . " m a i l A l l o w l i s t " = {
wantedBy = [ " t i m e r s . t a r g e t " ] ;
timerConfig = {
OnBootSec = " 1 h " ; # Run every hour
OnUnitActiveSec = " 1 h " ;
RandomizedDelaySec = " 1 0 m " ; # prevent overload on regular intervals
Unit = " m a i l A l l o w l i s t . s e r v i c e " ;
} ;
} ;
services = {
" m a i l A l l o w l i s t " = {
description = " A l l o w l i s t u p d a t e : P o s t t h e m a i l a d d r e s s e s t o t h e H R Z a l l o w l l i s t " ;
script = let
scriptTemplate = {
domain ,
allowlistPass ,
. . .
} : ''
echo " p r o c e s s ${ 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 " a u t h o r i z a t i o n : B a s i c $ ( < ${ cfg . stalwartAdmin } ) " http://localhost/api/principal | $ { pkgs . gnugrep } /bin/grep - o - e " [ A - Z a - z 0 - 9 . ! # \$ % & ' * + - / = ? ^ _ { | } ~ ] * @ ${ domain } " | tee /tmp/addresses
# This line searches for available redirects and adds them to the submission file.
2024-07-10 20:56:46 +00:00
$ { pkgs . gnugrep } /bin/grep - o - e " [ A - Z a - z 0 - 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
2024-07-10 20:56:46 +00:00
$ { 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
2024-07-10 20:56:46 +00:00
rm /tmp/addresses
'' ;
in
lib . strings . concatStringsSep " " ( map scriptTemplate cfg . domains ) ;
serviceConfig = {
Type = " o n e s h o t " ;
User = " s t a l w a r t - m a i l " ;
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 = " / t m p " ;
InaccessiblePaths = " - / l o s t + f o u n d " ;
PrivateDevices = true ;
PrivateUsers = true ;
ProtectHostname = true ;
ProtectClock = true ;
ProtectKernelTunables = true ;
ProtectKernelModules = true ;
ProtectKernelLogs = true ;
ProtectControlGroups = true ;
LockPersonality = true ;
MemoryDenyWriteExecute = true ;
RestrictRealtime = true ;
RestrictSUIDSGID = true ;
} ;
} ;
" s t a l w a r t - m a i l " = {
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
} ;
" v i r t - a l i a s e s - g e n e r a t o r " = {
description = " V i r t u a l A l i a s e s G e n e r a t o r : G e n e r a t e a s i e v e s c r i p t f r o m t h e v i r t u a l a l i a s f i l e " ;
2024-12-14 16:31:31 +00:00
script = lib . strings . concatStringsSep " " ( [ " ${ pkgs . alias-to-sieve } / b i n / a l i a s _ t o _ s i e v e " ] ++ map ( x : " ${ x . virt_aliases } ${ x . domain } " ) cfg . domains ++ [ " > / t m p / v i r t _ a l i a s e s " ] ) ;
2024-07-10 20:56:46 +00:00
wantedBy = [ " s t a l w a r t - m a i l . s e r v i c e " ] ; # Rerun on stalwart restart because forwardings may have changed.
serviceConfig = {
Type = " o n e s h o t " ;
User = " s t a l w a r t - m a i l " ;
NoNewPrivileges = true ;
# See https://www.man7.org/linux/man-pages/man5/systemd.exec.5.html
PrivateTmp = false ;
ProtectHome = true ;
ReadOnlyPaths = " / " ;
ReadWritePaths = " / t m p " ;
InaccessiblePaths = " - / l o s t + f o u n d " ;
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 = [
" / v a r / l i b / s t a l w a r t - m a i l / d a t a "
] ;
encryption . mode = " n o n e " ; # Otherwise the key is next to the backup or we have human interaction.
environment = {
BORG_RSH = " s s h - i / r u n / s e c r e t s / b a c k u p K e y " ;
# “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 = " y e s " ;
} ;
repo = " b o r g @ 1 9 2 . 1 6 8 . 1 . 1 1 : k a l u u t " ; # TODO for https://gitea.mathebau.de/Fachschaft/nixConfig/issues/33
startAt = " d a i l y " ;
user = " r o o t " ;
group = " r o o t " ;
} ;
} ;
}