+++
title = "A Reasonably Secure Mailserver Installation"
description = "installing postfix into a systemd-nspawn container with SPF/DKIM/DMARC support"
date = "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](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=postfix).
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:

```sh
[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](https://wiki.archlinux.org/title/Systemd-nspawn#Root_login_fails)
in the container before it'll let you login:

```sh
[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):

```sh
[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](https://wiki.archlinux.org/title/Virtual_user_mail_system_with_Postfix,_Dovecot_and_Roundcube#Testing).
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:

```sh
$ 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:
```sh
[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:
```sh
[root@postfix /]# useradd --create-home --user-group vmail
```

edit `etc/postfix/main.cf`:
```diff
- mail_owner = postfix
+ mail_owner = vmail
```

edit `etc/postfix/aliases`:
```diff
- root: you
+ root: vmail
```

edit `etc/postfix/virtual`:
```diff
- @uninsane.org root
+ @uninsane.org vmail
```

update the database mappings and then restart the services:
```sh
[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:

```diff
- 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:
- `$config_directory`
- `$daemon_directoy`
- `$sample_directory`
- `$readme_directory`
- `$html_directory`
- `$shlib_directory`
- `$manpage_directory`

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:

- 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:
```sh
[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:

- update the `Domain` field
- point the `KeyFile` to `/home/vmail/dkim/mx1.private` (created later)
- 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`:
```sh
# 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):
```sh
[vmail@postfix /home/vmail]$ mkdir dkim && cd dkim
[vmail@postfix /home/vmail/dkim]$ opendkim-genkey -r -s mx1 -d uninsane.org
```

start the service:
```sh
[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:
```sh
[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:

```asm
; 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:
```sh
[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:
```toml
[Network]
VirtualEthernet=on
Port=25:25
```

then you can stop the machine, restart it, and administer it:
```sh
[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](https://en.wikipedia.org/wiki/Dovecot_\(software\)).
