uninsane/content/blog/2022-04-05-systemd-nspawn-postfix.md

10 KiB

+++ title = "A Reasonably Secure Mailserver Installation" description = "" date = "2022-04-05" extra.hidden = true +++

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 10 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 modular design -- 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 operating 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 a [temporary] 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 /]$ systemd-nspawn -b --network-veth -p 25:25 -D /opt/postfix
postfix login: root
Password: <enter it>

[root@postfix ~]# systemctl enable systemd-resolved && systemctl start systemd-resolved
[root@postfix ~]# systemctl enable postfix && systemctl start postfix
# then create the db which postfix uses to map 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. append @uninsane.org root to the bottom of etc/postfix/virtual, add virtual_alias_maps = hash:/etc/postfix/virtual to etc/postfix/main.cf and then (in the container) run postmap /etc/postfix/virtual and restart the service.

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

change mail_owner in etc/postfix/main.cf to be vmail, and restart the service. in etc/postfix/aliases change root: you to root: vmail. then change etc/postfix/virtual to map to vmail by appending this to the bottom:

@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

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:

  • 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 outgoing messages:

[root@host /opt/postfix]$ cp usr/share/doc/opendkim/opendkim.conf.sample \
                             etc/opendkim/opendkim.conf
# update the `Domain` field
# point the `KeyFile` to /home/vmail/dkim/mx1.private (we'll generate that in a second)
# set `UserID` to `vmail`
# make sure `Socket` points to `inet:8891@localhost`
# and consider changing Canonicalization from simple/simple to relaxed/simple

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 start opendkim && systemctl enable 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 me!), but i'll skip that here. throw in the MX record, and your zone file should look like this:

@                     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/.

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

if these fail, check journalctl -u postfix. if there's nothing showing traffic, it may be that your ISP blocks 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).

in my case, Centurylink blocks both port 25 outbound and inbound, meaning that 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.