A Reasonably Secure Mailserver Installation
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.
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
/opt/postfix/etc/postfix/main.cf (it's fine for these to be the same: i use
uninsane.org for both)
--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
<container>), or from another box on your LAN (substitute the host's IP/name for
you probably don't want to expose this to the WAN yet:
$ nc <container> 25 helo uninsane.org mail from:<email@example.com> rcpt to:<firstname.lastname@example.org> data this is a test. . quit
mail should show up in the container at
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
nc command from above again, but use
rcpt to:<email@example.com and
the mail should be appended to that same
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
- mail_owner = postfix + mail_owner = vmail
- root: you + root: vmail
- @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
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.
etc/postfix/postfix-files, comment out every line which starts with one of:
since Arch manages these (correctly), you're not really losing anything.
nc command again: this time mail should show up in
and that file should be owned by the
vmail user instead of
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:
- a SPF DNS record (instructs recipients to enforce that your message originates from a specific IP)
- a DKIM DNS record (signs the message content with a key owned by your mailserver)
- a DMARC DNS record (allows you to receive reports from recipient mailservers)
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
[root@host /opt/postfix]$ cp usr/share/doc/opendkim/opendkim.conf.sample \ etc/opendkim/opendkim.conf
etc/opendkim/opendkim.conf in an editor and:
- update the
- point the
- make sure
- and consider changing Canonicalization from
then append this to
# 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@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
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 firstname.lastname@example.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: email@example.com 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:firstname.lastname@example.org;ruf=mailto:email@example.com" )
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.
/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.