paulgorman.org/technical

OpenBSD Mail Smarthost

(April 2018)

Configure OpenBSD as virtual (KVM/libvirt) mail smarthost. Screen for mail spam, viruses, and other badness, then pass the mail to our internal mail server.

Requirements:

Initial VM Install

Grab the OpenBSD ISO (https://www.openbsd.org/ftp.html).

$  touch create-openbsd-smarthost-vm.sh
$  chmod u+x create-openbsd-smarthost-vm.sh
$  cat create-openbsd-smarthost-vm.sh
#!/bin/sh
set -euf
vm=openbsd-6.3-smarthost
qemu-img create -f qcow2 -o preallocation=metadata ~/libvirt/"$vm".qcow2 20G
virt-install \
        --name="$vm" \
        --ram=2048 \
        --vcpus=1 \
        --network bridge=br0,model=e1000 \
        --os-variant openbsd6.2 \
        --cdrom ~/libvirt/openbsd-install63.iso \
        --disk ~/libvirt/"$vm".qcow2

During the brief install process:

Initial Post-Install Setup

If we added a non-root user during install, add them to the wheel group:

#  usermod -G wheel paul

Add an additional user if desired:

#  useradd -mG wheel scott

Create /etc/doas.conf:

permit nopass keepenv :wheel

Create /etc/installurl:

https://ftp.openbsd.org/pub/OpenBSD

Run updates:

#  syspatch
#  pkg_add -Uu
#  pkg_info -Q vim
#  pkg_add vim-8.0.1589-no_x11 bash coreutils amavisd-new amavisd-new-utils p5-Mail-SpamAssassin clamav unrar lz4 tnef

Create /etc/pf.conf (which we’ll add to in the “spamd” section below):

set block-policy drop
set skip on { lo }
block drop in quick from urpf-failed to any
block drop log all
pass in inet proto tcp from any to any port { 22 25 443 587 }
pass in inet proto icmp from 10.0.0.0/24 to self
pass out on egress proto { tcp udp icmp icmp6 } all modulate state

Test the PF rules, then reload:

#  pfctl -vnf /etc/pf.conf
#  pfctl -F all -f /etc/pf.conf

Install any SSH keys, if desired.

In /etc/ssh/sshd_config disable root logins:

PermitRootLogin no

Generate an SSH key for root:

#  ssh-keygen -b 4096

Create /etc/daily.local. See daily(8).

VERBOSESTATUS=0

What services do we need to configure?

$  rcctl ls all | grep -i -e spam -e clam -e amavis -e smtp
amavis_mc
amavisd
clamav_milter
clamd
freshclam
smtpd
spamassassin
spamd
spamd_black
spamlogd

AD/LDAP Integration

If we wanted to integrate with Active Directory, or a similar LDAP service, we could using the openldap-client package. After configuring LDAP, do something like this in smtpd.conf:

table vusers ldap:/etc/mail/ldap.conf
table vdomains ldap:/etc/mail/ldap.conf

However, since we’re using the smarthost as a chokepoint that does not forward mail for just any AD user, we forgo LDAP here.


Spamd

Read the spamd(8) man page.

spamd has three important intervals set with the -G flag, as decribed in the man page spamd(8).

The default values are “25:4:864”.

When a foreign mail server connects for the first time, spamd saves a tupple of foreign server IP address, sender email address, and recipient address. Then, it closes the connection with a temporary error. The “25” value means that spamd ignores further connection attempts from that host for twenty-five minutes. Spamd calls this the passtime.

After twenty-five minutes elapses, spamd will watch for connections matching the recorded tuple for four hours from the original attempt (the second argument to -G, the “4”). If the foreign host tries to resend with the same tuple, between twenty-five minutes and four hours following the original attempt, spamd adds the foreign server IP address to its whitelist. Spamd calls this the greyexp.

A host remains in spamd’s whitelist for 864 hours (i.e., thirty-six days). Spamd calls this the whiteexp.

To change the default values with which spamd starts, add arguments to /etc/rc.conf.local, like:

spamd_flags="-v -4 -G 25:4:864 -h mail.example.com -n \"Sendmail 8.14.4/8.14.4\" -C /etc/ssl/example.com.crt -K /etc/ssl/private/example.com.key"

Edit /etc/pf.conf to add spamd-specific rules:

table <spamd-white> persist
table <nospamd> {10.0.0.11}
set block-policy drop
set skip on { lo }
block drop in quick from urpf-failed to any
block drop log all
pass in on egress inet proto tcp from any to any port smtp divert-to 127.0.0.1 port spamd
pass in on egress proto tcp from <nospamd> to any port smtp
pass in log on egress proto tcp from <spamd-white> to any port smtp
pass in inet proto tcp from any to any port { 22 443 587 }
pass in inet proto icmp from 10.0.0.0/24 to self
pass out on egress proto { tcp udp icmp icmp6 } all modulate state
pass out log on egress proto tcp to any port smtp

Edit /etc/mail/spamd.conf:

all:\
    :nixspam:whitelist:blacklist:whitelist:

nixspam:\
    :black:\
    :msg="Your address %A is in the nixspam list\n\
    See http://www.heise.de/ix/nixspam/dnsbl_en/ for details":\
    :method=http:\
    :file=www.openbsd.org/spamd/nixspam.gz

whitelist:\
    :white:\
    :method=file:\
    :file=/etc/mail/nospamd:

blacklist:\
    :black:\
    :msg="Your address %A is blacklisted by this mail server":\
    :method=file:\
    :file=/etc/mail/blacklist:

(Indent with spaces not tabs.)

Create stub files for the white and black lists:

#  touch /etc/mail/nospamd
#  touch /etc/mail/blacklist

Add to /etc/syslog.conf:

!spamd
*.*                      /var/log/maillog

Turn on spamd:

#  rcctl enable spamd

Edit root’s crontab to enable updates of the spamd lists:

0       *       *       *       *       sleep $((RANDOM \% 2048)) && /usr/libexec/spamd-setup

The daemon spamlogd (see spamlogd(8)) watches the PF log. It’s responsible for adding IP’s to the spamd whitelist in /var/db/spamd. The spamlogd daemon only notices connections logged by PF, so make sure to tag the appropriate rules in /etc/pf.conf with log! Beyond that, spamlogd requires no configuration.

OpenSMTPD

Edit /etc/mail/aliases:

root: alert@example.com

And run:

# newaliases

Since we want to HELO to other mail servers with a name other than our true hostname, create /etc/mail/mailname:

mail.example.com

Since we prefer to work with uncompressed mail logs, and keep logs for longer, edit /etc/newsyslog.conf:

- /var/log/maillog                           640  7     *    24    Z
+ /var/log/maillog                           640  31    *    24

Edit /etc/mail/smtpd.conf:

table aliases file:/etc/mail/aliases
listen on all
accept from any for domain "example.com" relay

(Note that we add to the smtpd.conf as these notes progress.)

If necessary (without a proper internal MX record, for example) we can specify the relay:

accept from any for domain "example.com" relay via "postoffice.example.com"

Restart OpenSMTPD:

# rcctl restart smtpd

At this point, we’re relaying mail.

Now, set up secure mail submission on port 587. Since we’re only doing this for a handful of machine/admin accounts, we authenticate against a simple file Use the smtpctl encrypt command to encrypt passwords for the file.

Create the file /etc/mail/passwd-smtps:

mailuser $2b$09$M1451uuhfn6ZD0Gafd3NPOWioR3gdEodzXn/ZI/Y4sSdjbkm74DNi

And:

#  chmod 0660 /etc/mail/passwd-smtps

Edit /etc/mail/smtpd.conf:

pki example certificate "/etc/ssl/example.com.crt"
pki example key "/etc/ssl/private/example.com.key"
table aliases file:/etc/mail/aliases
table passwd-smtps file:/etc/mail/passwd-smtps
listen on all
listen on all port 587 tls-require pki example auth <passwd-smtps>
accept from any for domain "example.com" relay

Amavis, ClamAV, and SpamAssassin

Why Amavis? Amavis provides a unified interface between the MTA, which speaks SMTP, and various filter programs, many of which do not speak SMTP.

The package installer created an _vscan user and group.

Edit /etc/amavisd.conf. Verify:

$daemon_user  = '_vscan';
$daemon_group = '_vscan';
$inet_socket_port = 10024;

And set:

$mydomain = 'example.com'
$log_level = 1;
$log_recip_templ = $log_verbose_templ;
$sa_tag_level_deflt  = -999.0
$sa_tag2_level_deflt = 3.0;
...
@score_sender_maps = ({
	...
	read_hash("/etc/mail/amavis-senders"),
	...
)};
...
@av_scanners = (
 ['ClamAV-clamd',
   \&ask_daemon, ["CONTSCAN {}\n", "/tmp/clamd.sock"],
   qr/\bOK$/m, qr/\bFOUND$/m,
   qr/^.*?: (?!Infected Archive)(.*) FOUND$/m ],
...

Do:

#  touch /etc/mail/amavis-senders

The (many) other options can be left alone or tweaked as desired.

$  cat /etc/amavisd.conf | sed '/^[[:space:]]*#/d' | sed '/^$/d' | wc -l
     315

ClamAV

We have several new config files:

Edit /etc/freshclam.conf.

$  cat /etc/freshclam.conf | sed '/^#/d' | sed '/^$/d'
DatabaseMirror db.us.clamav.net
DatabaseMirror database.clamav.net

Set up a root cron job to update the malware definitions:

20  */4 *   *   *   /usr/local/bin/freshclam >/dev/null 2>&1

Edit /etc/clamd.conf.

$  cat /etc/clamd.conf | sed '/^#/d' | sed '/^$/d'
LocalSocket /tmp/clamd.sock
LocalSocketGroup _vscan
LocalSocketMode 660
User _vscan
MaxRecursion 12

The package installer created a _clamav user and group, but we run as the Amavis user.

SpamAssassin

Leave /etc/mail/spamassassin/local.cf at defaults. We set the score configuration, etc. in the Amavis config.

Set up Backups

#  mkdir -p /root/bin
#  touch /root/bin/backup.sh
#  u+x /root/bin/backup.sh
#  chmod u+x /root/bin/backup.sh

Edit the shell script:

#!/bin/sh
set -euf
host=$(hostname -s)
tar -C / -czf /var/backups/backup-"$host".tgz \
        etc \
        root \
        var/cron

Create a root crontab entry:

10  1   *   *   *   /root/bin/backup.sh

TODO: move backup tgz to a location where the enterprise backup job will see it.

Go!

Final /etc/mail/smtpd.conf:

pki mail.example.com certificate "/etc/ssl/example.com.crt"
pki mail.example.com key "/etc/ssl/private/example.com.key"

table aliases file:/etc/mail/aliases
table passwd-smtps file:/etc/mail/passwd-smtps
table relay-recipients file:/etc/mail/relay-recipients
table our-nets {10.0.0.0/16, 127.0.0.0/8}
table our-domains {example.com, example.net}

listen on all tls pki mail.example.com hostname mail.example.com
listen on lo0 port 10025 tag AMAVIS
listen on all port 587 tls-require pki mail.example.com auth <passwd-smtps> hostname mail.example.com

accept form local for local alias <aliases> deliver to mbox  # Send local mail according to aliases.
reject from ! source <our-nets> for ! domain <our-domains>  # No open relay!
reject from ! source <our-nets> sender "@example.com" for any
reject from any for domain <our-domains> recipient ! <relay-recipients>
accept tagged AMAVIS from source <our-nets> for any relay pki mail.example.com
accept tagged AMAVIS from any for domain <our-domains> relay pki mail.example.com
accept from any for any relay via smtp://127.0.0.1:10024  # Send to Amavis.

We added the reject rule (and the file /etc/mail/relay-recipients) to only relay mail for some internal addresses

Enable all services, and start them:

#  rcctl enable freshclam clamd spamassassin amavisd smtpd
#  rcctl start freshclam clamd spamassassin amavisd smtpd

Outlooks and Gmail don’t play well with Graylisting!

spamd uses a tuple of sending server IP, sender email address, and recipient email to greylist. Unfortunately, some sender have big pools of machines, so the retry send may come from a different IP than the one listed in the spamdb tuple.

A kind soul wrote a shell script to grab SPF records from such senders, and feed them to PF.

Install the spf_fetch utility:

--- nostromo ~ $  mkdir repo
--- nostromo ~ $  cd repo/
--- nostromo repo $  git clone https://github.com/akpoff/spf_fetch
--- nostromo repo $  cd spf_fetch/
--- nostromo spf_fetch $  doas make install
cp spf_fetch     "/usr/local/bin/"
cp spf_update_pf "/usr/local/bin/"
cp experimental/spf_mta_capture "/usr/local/bin/"
cp spf_fetch.1     "/usr/local/man/man1/"
cp spf_update_pf.1 "/usr/local/man/man1/"
cp experimental/spf_mta_capture.1 "/usr/local/man/man1/"

Define a list of common/safe (but greylist-breaking) senders:

--- nostromo ~ $  doas vim /etc/mail/common_domains
amazonses.com
aol.com
att.net
charter.net
comcast.net
cox.net
email.com
gmail.com
googlemail.com
google.com
hotmail.com
icloud.com
mac.com
me.com
mail.com
msn.com
live.com
outlook.com
sbcglobal.net
verizon.net
yahoo.com

Hook into PF:

--- nostromo ~ $  doas vim /etc/pf.conf
table <spamd-white> persist
table <nospamd> persist file "/etc/mail/nospamd"
table <common_white> persist file "/etc/mail/common_domains_ips"
set block-policy drop
set skip on { lo }
block drop in quick from urpf-failed to any
block drop log all
pass in on egress inet proto tcp from any to any port smtp divert-to 127.0.0.1 port spamd
pass in log on egress proto tcp from <spamd-white> to any port smtp
pass in log on egress proto tcp from <nospamd> to any port smtp
pass in log on egress proto tcp from <common_white> to any port smtp
pass in inet proto tcp from any to any port { 22 443 587 }
pass in inet proto icmp from 10.0.0.0/24 to self
pass out on egress proto { tcp udp icmp icmp6 } all modulate state
pass out log on egress proto tcp to any port smtp
--- nostromo ~ $  doas touch /etc/mail/common_domains_ips
--- nostromo ~ $  doas pfctl -F all -f /etc/pf.conf

Note that the table name spamd-white is hard-coded. See spamd(8).

Give it a whirl:

--- nostromo ~ $  doas spf_update_pf
247 addresses added.
--- nostromo ~ $  doas pfctl -T show -t common_white
17.36.0.0/16
17.41.0.0/16
17.58.0.0/16
17.110.0.0/15
17.111.110.0/23
...

New smtpd.conf Grammar and Rule Revisions

As of mid-2018, a new grammar for smtpd.conf is on the horizon.

The big change splits rules into an “action” line and a “match” line.

Furthermore, we want to be able to reject mail from some IP networks and set different Amavis policies for main setn to our users (amavis-them) and mail sent by our users (amavis-us).

Although I have yet to test this, our updated smtpd.conf will look something like:

pki mail.example.com cert "/etc/ssl/example.com.crt"
pki mail.example.com key "/etc/ssl/private/example.com.key"

table aliases file:/etc/mail/aliases
table bad-domains {example.com, "*.example.com", example.net, "*.example.net", example.org, "*.example.org", "*.example", "*.invalid", "*.local", "*.test"}
table bad-nets file:/etc/mail/bad-nets
table passwd-smtps file:/etc/mail/passwd-smtps
table relay-recipients file:/etc/mail/relay-recipients
table our-nets {10.0.0.0/16, 203.0.113.22, 192.0.2.0/24}
table our-domains {example.com, example.net}

listen on all tls pki mail.example.com hostname mail.example.com
listen on lo0 port 10025 tag AMAVIS-THEM
listen on lo0 port 10027 tag AMAVIS-US
listen on all port 587 tls-require pki mail.example.com auth <passwd-smtps> hostname mail.example.com

action "local" mbox alias <aliases>
action "relay" relay
action "amavis-them" relay host smtp://127.0.0.1:10024
action "amavis-us" relay host smtp://127.0.0.1:10026

match for domain <bad-domains> reject
match from local for local action "local"
match from any for domain <our-domains> recipient ! <relay-recipients> reject
match from src <bad-nets> for any reject
match ! tag AMAVIS-US from src <our-nets> for any action "amavis-us"
match tag AMAVIS-US for any action relay
match ! tag AMAVIS-THEM from any for domain <our-domains> action "amavis-them"
match tag AMAVIS-THEM for domain <our-domains> relay

https://man.openbsd.org/smtpd.conf

DKIM (and SPF)

DKIM and SPF help assure mail integrity. The make spoofing harder.

SPF lets a receiving mail server check a DNS record to verify that the sending mail server’s IP address is allowed to send mail for the domain in the envelope Sender address. Setting up SPF for our domain doesn’t require any changes to our OpenBSD mail config; we only need to create TXT DNS records, like:

@                   IN  TXT  "v=spf1 mx -all"
mail.example.com.   IN  TXT  "v=spf1 a -all"
mail2.example.com.  IN  TXT  "v=spf1 a -all"

DKIM uses a public/private key pair to cryptographically sign some or all of a message body, typically including the From header. Receiving mail servers can check the signature to verify From headers originated at that domain. (This is not absolutely fool-proof; e.g., some spammers have been known to resend signed message headers, but append bad contents to the body.)

As with SPF, we publish a TXT DNS record containing the public half of our DKIM key. Note that TXT records are limited to 255 characters in a single string, so long keys must be split for some DNS server. Route 53 requires us to enter a split value, while Windows DNS requires a whole, unsplit value. A TXT record for “myselector._domainkey.example.com” might have the split value:

"v=DKIM1\; k=rsa\; p=QzI8IjANBgkbhkiG9w0BA8EFAAOCAQ8AMIIBCgKCAQEAqCxxZduA47anRLt+SLDiNFK6Vh4OQhyfp/OvHqVQioE27T1cO723Jjth0sevmk6ufIFQ2wYBk5uzfr1FmvI5UUVWcDd/yT47xOKW9Y852BWFpkIIovY4dvb3Px7VKSZhijyjifEC1ewQb"
"SM12QDeL854scXyd48EW803Njsn98vvPi1uOKMMOPbfIrAzD7a9vN/4QNdF/X9g5AUaytjsXx+p/V1jTgcySJbJ72SVwhTaL2bKISUZ8Uj7oayfel/i1PkNhsFHoSczujt+pyxbi80mjRWj6yX6jzPTKzG77jcvSaofNoUFeHyl6JplyvG48Z+gIQm6STSJsokr9UA+fQIzAQA0"

However, DKIM requires further configuration. DKIM has two halves: adding signatures to outbound messages, and checking signatures of inbound messages. OpenSMTPD relies on an external daemon, like the dkimproxy package, to sign outbound mail.

#  pkg_add dkimproxy
#  mkdir -p /etc/ssl/dkim/private
#  chmod 750 /etc/ssl/dkim/private
#  openssl genrsa -out /etc/ssl/dkim/private/private.key 2048
#  openssl rsa -in /etc/ssl/dkim/private/private.key -pubout -out /etc/ssl/dkim/public.key
#  tail -1 /etc/passwd 
_dkimproxy:*:721:721:dkimproxy user:/nonexistent:/sbin/nologin
#  chown -R root:_dkimproxy /etc/ssl/dkim
$  cp /etc/dkimproxy_out.conf /tmp/
#  doas vim /etc/dkimproxy_out.conf
$  diff -u /tmp/dkimproxy_out.conf /etc/dkimproxy_out.conf

	--- /tmp/dkimproxy_out.conf     Thu Mar 14 12:50:43 2019
	+++ /etc/dkimproxy_out.conf     Thu Mar 14 13:01:20 2019
	@@ -1,21 +1,21 @@
	 # specify what address/port DKIMproxy should listen on
	-listen    127.0.0.1:10027
	+listen    127.0.0.1:10030

	 # specify what address/port DKIMproxy forwards mail to
	-relay     127.0.0.1:10028
	+relay     127.0.0.1:10031

	 # specify what domains DKIMproxy can sign for (comma-separated, no spaces)
	-domain    example.org
	+domain    example.com

	 # specify what signatures to add
	 signature dkim(c=relaxed)
	 signature domainkeys(c=nofws)

	 # specify location of the private key
	-keyfile   /full/path/to/private.key
	+keyfile   /etc/ssl/dkim/private/private.key

	 # specify the selector (i.e. the name of the key record put in DNS)
	-selector  selector1
	+selector  myselector

#  rcctl enable dkimproxy_out
#  rcctl start dkimproxy_out

Modify /etc/mail/smtpd.conf to add DKIM a signature after we run the outbound message through Amavis:

pki mail.example.com certificate "/etc/ssl/STAR_example_com.crt"
pki mail.example.com key "/etc/ssl/private/STAR_example_com.key"

table aliases file:/etc/mail/aliases
table bad-domains {example.com, "*.example.com", example.net, "*.example.net", example.org, "*.example.org", "*.example", "*.invalid", "*.local", "*.test"}
table bad-nets file:/etc/mail/bad-nets
table passwd-smtps file:/etc/mail/passwd-smtps
table relay-recipients file:/etc/mail/relay-recipients
table our-nets {10.0.0.0/16, 203.0.113.22, 192.0.2.0/24}
table our-domains {example.com, example.net}

listen on all tls pki mail.example.com hostname mail.example.com
listen on lo0 port 10025 tag AMAVIS-THEM
listen on lo0 port 10027 tag AMAVIS-US
listen on lo0 port 10031 tag DKIM-OUT
listen on all port 587 tls-require pki mail.example.com auth <passwd-smtps> hostname mail.example.com

reject for domain <bad-domains>
accept from local for local alias <aliases> deliver to mbox
reject from any for domain <our-domains> recipient ! <relay-recipients>
reject from source <bad-nets>
accept tagged ! DKIM-OUT from source <our-nets> for any relay via smtp://127.0.0.1:10030
accept tagged DKIM-OUT from local for any relay via smtp://127.0.0.1:10026
accept tagged AMAVIS-US from local for any relay pki mail.example.com
accept tagged ! AMAVIS-THEM from any for domain <our-domains> relay via smtp://127.0.0.1:10024
accept tagged AMAVIS-THEM from local for domain <our-domains> relay pki mail.example.com
reject

For incoming DKIM….

http://dkimproxy.sourceforge.net/usage.html

Using DKIMproxy to verify incoming messages

First off, if you use SpamAssassin, you may not want to use DKIMproxy for verifying messages. SpamAssassin includes a DKIM plugin which uses the same Mail::DKIM module to verify messages as DKIMproxy itself. All you need to do is install the Mail::DKIM module (available on the Download page) and enable the plugin in SpamAssassin’s config.

References

mail flow diagram