As my old mailserver crashed a few weeks ago for reasons related to it being a Raspberry Pi, I’ve had to quickly recreate one to receive a very important email in an hour. I found that most of the online tutorials to set up a proper mailserver are quite incomplete and outdated, so here’s my take on how to set it all up.

My set-up is based on Ubuntu 18.04 Bionic Beaver, and includes DKIM signatures on outgoing mail, and DMARC verification and reports. All this (save for DMARC reports) I already had, but Archlinux is significantly different from Ubuntu, so there’s some interesting hurdles.

Configuring the server

I’m going to assume that you’ve already installed a regular ol’ Postfix mailserver. If you haven’t, find one of the tens of thousands guides available elsewhere (or just the Arch wiki) and follow that. I’ll just show you what you need to do to add DMARC and DKIM.

First, let’s install the dependencies:

1
sudo apt install opendkim opendmarc

Setting up OpenDKIM

OpenDKIM will do two things for us: validate DKIM signatures on incoming email, and sign our outgoing email. The latter requires us to generate our own key if you don’t already have one. The tool you need is called opendkim-genkey and it is located in the opendkim-tools package. For more details, you can just look up the instructions on the Arch wiki. When generating a key, the tool also generates a DNS record which should be configured.

Assuming you’ve got your DNS set-up, you need to edit the configuration file. In /etc/opendkim.conf there are a few things of interest. The only fields you really need to change are as follows:

1
2
3
Domain          comma.separated, list.of, your.domains
KeyFile         /path/to/your/key.private
Selector        your_key_selector

The selector is the selector you chose when generating the DKIM key.

Setting up OpenDMARC

Ubuntu’s default configuration for OpenDMARC is pretty good; all the default values worked for me. However, its default configuration doesn’t actually validate SPF. Instead, it will just assume any SPF validation header is valid. This is not what you want. You also probably want it to ignore authenticated clients, so that you can still send emails. To do that, you can simply add the following to /etc/opendmarc.conf:

1
2
3
4
5
6
## Allow authenticated users to do whatever.
IgnoreAuthenticatedClients true

## Manually check for SPF.
SPFIgnoreResults true
SPFSelfValidate true

Working around the chroot

By default, on Ubuntu, postfix is configured to run in a chroot for security reasons. This means that we need to expose the sockets for both OpenDKIM and OpenDMARC in that chroot. To do that, we first need to make sure that both have a directory available to them, and then change their socket locations. First part: the directories.

1
2
3
4
5
6
7
for service in opendmarc opendkim; do
  # Create run directory
  sudo mkdir -p /var/spool/postfix/var/run/$service;
  sudo chown $service:
  # Add postfix to the group
  sudo usermod -a -G $service postfix
done

Now we need to update the socket location for both daemons so that they actually put it in the right place. Just find the right lines and replace them with the following:

1
2
3
4
# /etc/opendkim.conf
Socket local:/var/spool/postfix/var/run/opendkim/opendkim.sock
# /etc/opendmarc.conf
Socket local:/var/spool/postfix/var/run/opendmarc/opendmarc.sock

With that enabled, we can now add these two programs to milter (mail filter) lists, and then everything should work. We can add the following lines to Postfix’s main.cf:

1
2
3
# OpenDKIM/OpenDMARC
non_smtpd_milters=unix:/var/run/opendkim/opendkim.sock, unix:/var/run/opendmarc/opendmarc.sock
smtpd_milters=unix:/var/run/opendkim/opendkim.sock, unix:/var/run/opendmarc/opendmarc.sock

Now we can restart (not reload, otherwise the added groups for Postfix won’t propagate) Postfix, and tada: you are now validating DMARC.

There are still some caveats. Currently, your set-up does only flags invalid emails, but still receives them. You can instruct OpenDMARC to reject them instead.

Bonus: sending DMARC reports

With the set-up described in this post, you will be validating all incoming email for DMARC conformance, but by default you won’t be sending the actual reports. These reports are supposed to inform senders of who’s spoofing their email (in case of failures) or in general who is receiving their mail. Doing so may be a privacy issue as you are notifying people that you are receiving their email.

The reporting tools built into OpenDMARC require MySQL to function but I personally don’t like Oracle very much, so I prefer MariaDB. This database system is mostly compatible with anything that supports MySQL, and it has a few extra goodies. You can install either one, and create a database and user for opendmarc.

One thing it doesn’t have, however, is the InnoDB patch that allows for key sizes of arbitrairy length. This is unfortunate, because the default schema for OpenDMARC’s (located in /usr/share/doc/opendmarc/schema.mysql) administrative database goes over that limit. Fortunately, there is very little harm in shrinking the affected fields to be under the limit, unless you frequently encounter domain names of over 191 code-points (not bytes) in length. The fixed schema is shown below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
CREATE DATABASE IF NOT EXISTS opendmarc;
USE opendmarc;
SET TIME_ZONE='+00:00';

-- A table for mapping domain names and their DMARC policies to IDs
CREATE TABLE IF NOT EXISTS domains (
	id INT NOT NULL AUTO_INCREMENT,
	name VARCHAR(191) NOT NULL,
	firstseen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

	PRIMARY KEY(id),
	UNIQUE KEY(name)
);

-- A table for logging reporting requests
CREATE TABLE IF NOT EXISTS requests (
	id INT NOT NULL AUTO_INCREMENT,
	domain INT NOT NULL,
	repuri VARCHAR(255) NOT NULL,
        adkim TINYINT NOT NULL,
        aspf TINYINT NOT NULL,
        policy TINYINT NOT NULL,
        spolicy TINYINT NOT NULL,
        pct TINYINT NOT NULL,
        locked TINYINT NOT NULL DEFAULT '0',
	firstseen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
	lastsent TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:01',

	PRIMARY KEY(id),
	KEY(lastsent),
	UNIQUE KEY(domain)
);

-- A table for reporting hosts
CREATE TABLE IF NOT EXISTS reporters (
	id INT NOT NULL AUTO_INCREMENT,
	name VARCHAR(191) NOT NULL,
	firstseen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

	PRIMARY KEY(id),
	UNIQUE KEY(name)
);

-- A table for IP addresses
CREATE TABLE IF NOT EXISTS ipaddr (
	id INT NOT NULL AUTO_INCREMENT,
	addr VARCHAR(64) NOT NULL,
	firstseen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

	PRIMARY KEY(id),
	UNIQUE KEY(addr)
);

-- A table for messages
CREATE TABLE IF NOT EXISTS messages (
	id INT NOT NULL AUTO_INCREMENT,
	date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
	jobid VARCHAR(128) NOT NULL,
	reporter INT UNSIGNED NOT NULL,
	policy TINYINT UNSIGNED NOT NULL,
	disp TINYINT UNSIGNED NOT NULL,
	ip INT UNSIGNED NOT NULL,
	env_domain INT UNSIGNED NOT NULL,
	from_domain INT UNSIGNED NOT NULL,
	policy_domain INT UNSIGNED NOT NULL,
	spf TINYINT NOT NULL,
	align_dkim TINYINT UNSIGNED NOT NULL,
	align_spf TINYINT UNSIGNED NOT NULL,
	sigcount TINYINT UNSIGNED NOT NULL,

	PRIMARY KEY(id),
	KEY(date),
	UNIQUE KEY(reporter, date, jobid)
);

-- A table for signatures
CREATE TABLE IF NOT EXISTS signatures (
	id INT NOT NULL AUTO_INCREMENT,
	message INT NOT NULL,
	domain INT NOT NULL,
	pass TINYINT NOT NULL,
	error TINYINT NOT NULL,

	PRIMARY KEY(id),
	KEY(message)
);

-- CREATE USER 'opendmarc'@'localhost' IDENTIFIED BY 'changeme';
-- GRANT ALL ON opendmarc.* to 'opendmarc'@'localhost';

We then need to convince OpenDMARC to start writing its judgements to a file. It doesn’t really matter where it stores them, so I store them in the run directory. To do so, you just need to append the following to /etc/opendmarc.conf:

1
2
## Save history
HistoryFile /run/opendmarc/opendmarc.dat

After that, we need a daily cronjob that will collect all of this data into the database, send out all the reports, and (optionally) discard old records. There are various cronjobs listed online, but I figured I’d write my own. I use the default username and database name so I don’t have to specify them to each command, but you can add those with --dbuser and --dbname respectively.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# File: /etc/cron.daily/dmarc-reports
#!/bin/bash
HISTFILE=/var/run/opendmarc/opendmarc.dat
# DBUSER=opendmarc
# DBNAME=opendmarc
DBPASS=s3cr37.P455w0rd
REPORT_ORG=example.org
REPORT_EMAIL=dmarc_reports@example.org

[[ ! -f $HISTFILE ]]; then
   echo "No DMARC history file found."
   exit 1
fi

OLDHIST="$HISTFILE.old"

mv -f "$HISTFILE" "$OLDHIST"

# Import the new history
opendmarc-import --dbpasswd="$DBPASS" < "$OLDHIST"
# Send the reports to servers that haven't had a report in the last day
opendmarc-reports --dbpasswd="$DBPASS" --report-email="$REPORT_EMAIL" \
     --interval=86400 --report-org="$REPORT_ORG"
# Optional: expire old data from the database to save space.
opendmarc-expire --dbpasswd="$DBPASS"

And that’s it! You should now be sending daily DMARC reports to everyone mailing you that wants one. You will probably notice that quite a few (spam) domains request DMARC reports, but that they don’t actually accept emails. This is unfortunate, and you can probably filter them, which I may write about in the future. Until then, enjoy your system.