/ home / blog about

A Reasonably Secure Mailserver Installation

2022/04/06

i need software to receive emails, and possibly to send them too. i.e., a mailserver. the mature mailserver implementations were all written in a time where security was even worse than today. Postfix is among the better ones, but even it has a fair number of CVEs. its intended operation -- where it writes to mailboxes owned by different users -- relies on elevated access control. although the risks are mitigated by its design around separation of concerns -- where only select portions of code get elevated permissions -- and the linux capabilities system, i still would not feel comfortable running this without isolating it from other applications on the same machine.

enter systemd-nspawn. nspawn is an extremely lightweight container. it's more of a transparent chroot: package up the userspace of some linux distribution, place it in a directory, and then nspawn uses the host kernel and performs the whole PID 1 boot sequence of that chroot, virtualizing all the fs access and isolating the processes/etc. we'll use this to create a container dedicated to postfix.

Installation

start by creating the rootfs and launching it as a container. this assumes the host is running Arch:

[root@host /]# pacman -S arch-install-scripts
[root@host /]# mkdir /opt/postfix
[root@host /]# pacstrap -c /opt/postfix base postfix openbsd-netcat opendkim perl
[root@host /]# systemd-nspawn -D /opt/postfix
 ># passwd  # choose some password you can remember for the rest of setup
 ># exit

if you're ssh'd into the host, you need to relax some security settings in the container before it'll let you login:

[root@host /opt/postfix]$ mv etc/securetty etc/securetty.OLD
[root@host /opt/postfix]$ mv usr/share/factory/etc/securetty \
                             usr/share/factory/etc/securetty.OLD
# then comment out the line containing securetty in usr/lib/tmpfiles.d/arch.conf

configure myhostname and mydomain in /opt/postfix/etc/postfix/main.cf (it's fine for these to be the same: i use uninsane.org for both)

using the --network-veth flag, systemd will create a NAT'd network and expose the downstream to the container. we can then forward ports across the NAT just like you would forward ports from your router to your PC/server (port 25 here is the SMTP port):

[root@host /]# systemd-nspawn -b --network-veth -p 25:25 -D /opt/postfix
postfix login: root
Password: <enter it>

[root@postfix ~]# systemctl enable --now systemd-resolved
[root@postfix ~]# systemctl enable --now postfix
# then create the db which maps email address to linux user accounts:
[root@postfix ~]# newaliases

for the record, /etc/postfix/aliases contains the mappings consumed by newaliases. the defaults work for us now but you'll want to tweak them later.

like HTTP, the SMTP grammar is human friendly. we can verify our setup with netcat. you can do this from within the container (substitute localhost for <container>), or from another box on your LAN (substitute the host's IP/name for <container>). you probably don't want to expose this to the WAN yet:

$ nc <container> 25
helo uninsane.org
mail from:<test@uninsane.org>
rcpt to:<root@uninsane.org>
data
this is a test.
.
quit

mail should show up in the container at var/spool/mail/root.

if this is intended as a single-user mailserver, you might want a catch-all mail rule:

[root@postfix /]# echo '@uninsane.org root' >> /etc/postfix/virtual
[root@postfix /]# echo 'virtual_alias_maps = hash:/etc/postfix/virtual' >> \
                        /etc/postfix/main.cf
[root@postfix /]# postmap /etc/postfix/virtual
[root@postfix /]# systemctl restart postfix

try the nc command from above again, but use rcpt to:<anything@uninsane.org and the mail should be appended to that same /var/spool/mail/root file.

Non-Root User

we'd prefer to be able to read mail without being root. so create a user dedicated to holding the mailboxes:

[root@postfix /]# useradd --create-home --user-group vmail

edit etc/postfix/main.cf:

- mail_owner = postfix
+ mail_owner = vmail

edit etc/postfix/aliases:

- root: you
+ root: vmail

edit etc/postfix/virtual:

- @uninsane.org root
+ @uninsane.org vmail

update the database mappings and then restart the services:

[root@postfix /]# newaliases
[root@postfix /]# postmap /etc/postfix/virtual
[root@postfix /]# postfix set-permissions
[root@postfix /]# systemctl restart postfix

the postfix Arch package includes the /var/spool files which are now owned by vmail, and AFAICT Arch fixes package permissions on each boot. so for these changes to take permanent effect, you'll need to edit lib/systemd/system/postfix.service to apply set-permissions on each boot:

- ExecStart=/usr/bin/postfix start
+ ExecStart=/usr/bin/bash -c '/usr/bin/postfix set-permissions \
+                             && /usr/bin/postfix start'

because systemd limits postfix's ability to write outside of /var/spool, you'll need to change which files postfix tries to enforce permissions on if you want this to succeed. in etc/postfix/postfix-files, comment out every line which starts with one of:

since Arch manages these (correctly), you're not really losing anything.

run that nc command again: this time mail should show up in /var/spool/mail/vmail, and that file should be owned by the vmail user instead of root.

now we can work on the WAN side of things. to prevent spoofing & improve the likelihood that your messages will be accepted by other servers, you'll want to add some DNS records to your zone file:

and of course you'll need a MX record so others know where to send mail.

DKIM and DNS

we installed opendkim during the earlier pacstrap invocation: now we'll configure it to sign outgoing messages:

[root@host /opt/postfix]$ cp usr/share/doc/opendkim/opendkim.conf.sample \
                             etc/opendkim/opendkim.conf

open etc/opendkim/opendkim.conf in an editor and:

then append this to etc/postfix/main.cf:

# For use by dkim milter
smtpd_milters = inet:localhost:8891
non_smtpd_milters = $smtpd_milters
milter_default_action = accept

generate the keys (run this as the vmail user):

[vmail@postfix /home/vmail]$ mkdir dkim && cd dkim
[vmail@postfix /home/vmail/dkim]$ opendkim-genkey -r -s mx1 -d uninsane.org

start the service:

[root@postfix /]# systemctl enable --now opendkim

add the mx1._domainkey TXT record (documented in /home/vmail/dkim/mx1.txt) into your zone file.

then run the nc example again. you should get mail that has an Authentication-Results header -- which fails, since we didn't sign our message.

using the postfix sendmail command we should be able to send something with a valid signature:

[root@postfix /]# sendmail test@uninsane.org
this message should be signed
.
[root@postfix /]# cat /var/mail/vmail
[...]
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=uninsane.org; s=mx1;
        t=[...]; bh=[...]
        h=Date:From;
        b=[...]
Message-Id: <YYYYMMDDTTTTTT.NNNNNNNNNNN@uninsane.org>
Date: [...]
From: root@uninsane.org

this message should be signed

then add a SPF DNS record and a DMARC record to receive delivery reports. if you're running a large mail server it would be good to install opendmarc to send delivery reports to other servers (like mine!), but i'll skip that here. throw in the MX record, and your zone file should look like this:

; mailserver shares an IP with the rest of uninsane.org.
@                     MX 10    uninsane.org.

; Sender Policy Framework:
;   +mx     => mail passes if it originated from the MX
;   +a      => mail passes if it originated from the A address of this domain
;   +ip4:.. => mail passes if it originated from this IP
;   -all    => mail fails if none of these conditions were met
@                     TXT      "v=spf1 ip4:203.0.113.1 a mx -all"

; DKIM public key:
mx1._domainkey        TXT      "v=DKIM1; k=rsa; s=email; p=<big long string>"

; DMARC fields <https://datatracker.ietf.org/doc/html/rfc7489>:
;   p=none|quarantine|reject: what to do with failures
;   sp = p but for subdomains
;   rua = where to send aggregrate reports
;   ruf = where to send individual failure reports
;   fo=0|1|d|s  controls WHEN to send failure reports
;     (1=on bad alignment; d=on DKIM failure; s=on SPF failure);
; Additionally:
;   adkim=r|s  (is DKIM relaxed [default] or strict)
;   aspf=r|s   (is SPF relaxed [default] or strict)
;   pct = sampling ratio for punishing failures (default 100 for 100%)
;   rf = report format
;   ri = report interval
_dmarc                TXT      (
    "v=DMARC1;p=quarantine;sp=reject;fo=1:d:s;"
    "rua=mailto:admin+mail@uninsane.org;ruf=mailto:admin+mail@uninsane.org"
)

Validation

validate your DMARC record (and DKIM, SPF if you want): https://dmarcian.com/dmarc-inspector/.

confirm that your domain/IP isn't blacklisted: https://multirbl.valli.org/.

try sending/receiving mail: https://www.appmaildev.com/en/dkim.

if these fail, check journalctl -u postfix. if there's no indication of traffic, it may be that your ISP blocks outbound port 25. you can check for that with nc -vz gmail.com 25 (will exit 0 if the port is open, hang if the port is blocked).

less probably, your ISP might block inbound port 25. check for that here: https://canyouseeme.org/.

in my case, Centurylink blocks both directions, so i can't even use this setup to receive mail. for this case, i'll explore running postfix on a non-standard port and using a mail forwarder or transparent proxy in a subsequent blog post.

but if your mail server is working, then instruct systemd to launch the container when the host boots. while the container's active, run:

[root@host /]# ln -s /opt/postfix /var/lib/machines/postfix
[root@host /]# machinectl enable postfix
[root@host /]# systemctl enable machines.target

alternatively, you could move the whole machine into /var/lib/machines/postfix instead of symlinking it.

populate /etc/systemd/nspawn/postfix.nspawn (you may need to create the directory) with the settings we used earlier:

[Network]
VirtualEthernet=on
Port=25:25

then you can stop the machine, restart it, and administer it:

[root@host /]# machinectl stop postfix
[root@host /]# machinectl start postfix
[root@host /]# machinectl login postfix

you now have a postfix instance which starts on boot and can send/receive mail as long as port 25 is accessible. later on you may want to provide client access in a friendlier way than directly reading the spool or invoking sendmail. that could be done by installing something like dovecot.