Running my own mailer - Adventures with OpenSMTPd

Warning: This is a stream of consciousness describing my latest adventure installing a mail server. It is mostly a reminder for myself but I am publishing it here, so it might be helpful for others as well. No I am not the owner of example.com.

As I am about to decentralize all the thing I am going to run my own mail server for family members. As an initial host I have chosen the cheapest DigitalOcean VM I could rent which should be more than sufficient to host the mail. I have followed this tutorial to install OpenBSD underhanded.

There are quite a few caveats to that:

(import foreign)
(use (srfi 1 4 13))

(define (random-bytes len)
  (cond-expand (openbsd:
                (arc4random len))
               (linux:
                (with-input-from-file "/dev/urandom" (lambda () (read-u8vector len))))
               (else
                (with-input-from-file "/dev/random" (lambda () (read-u8vector len))))))
#+openbsd
(define (arc4random len)
  (let ((buf (make-blob len)))
    ((foreign-lambda
      void
      "arc4random_buf"
      (scheme-pointer void)
      size_t) buf len)
    (blob->u8vector buf)))

(define wordlist-file "/usr/share/dict/ngerman")
(define words (with-input-from-file wordlist-file read-lines))
(define number-of-words (length words))
(define entropy-per-word
  (inexact->exact (floor (* (/ (log number-of-words) (log 2)) 100))))


(define (main entropy)
  (let ((needed-words (inexact->exact (ceiling (/ entropy entropy-per-word)))))
    (print "Loaded word list (" wordlist-file ") with " number-of-words " words.")
    (print "Generating a phrase of " needed-words " words with an entropy of " entropy-per-word " bits per word.")
    (let loop ((phrase '())
               (n needed-words))
      (let* ((r (u8vector->list (random-bytes 2)))
             (i (+ (arithmetic-shift (car r) 8) (cadr r))))
        (if (zero? n)
            (print "Phrase: '"(string-join (reverse phrase)) "'")
            (loop (cons (list-ref words (modulo i number-of-words)) phrase) (sub1 n)))))))
      
(main (string->number (cadr (argv))))

With this we get fine passphrases like "Dichterfürst Kassettentonbandgerät Jahreskongresse Hydraulikverbesserungen Bauskizzen Gräuelmärchen".

So after setting up the usual: user in wheel, doas.conf, ssh keys, disabling root logins, applying patches etc. The fun starts.

Set your hostname to something other than the domain name. I didn't which I've regretted dearly, as you might see later.

I have followed the tutorial on the OpenSMTPd FAQ almost verbatim. First I have installed opensmtpd-extras from packages, as it contains a passwd input filter which we will use in the opensmtpd config. Also dovecot as a way to get to the mails.

Things not mentioned in the tutorial: You want to use smtpctl encrypt to generate the passwords for submission authentication. Also don't forget to run newaliases.

Also I have thrown away all the dovecot conf files and replaced them with the output of dovecot -n. Who needs a conf.d directory with several layers of indirection? I don't for this simple setup.

Next try to connect to imap and smtpd. Second failure of mine was to configure the MUA to actually use the submission port instead of port 25. My second error is related to the hostname. Initially I had a hostname equal to the domain name, say 'example.com'. That's a bad idea as opensmtpd identifies "local" connections via the hostname. So what has happened to me: The second accept rule in the smtpd.conf file never matched since all connections are seen as local, even the ones from virtual users. So the lookup for the expansion went to the wrong file resulting in the very frustrating "invalid recipient" error message.

I have resolved this by renaming the host name to 'mailer.example.com'.

Next I wanted to be a good mail citizen so I added SPF and DKIM to my setup. So next install dkimproxy from packages. This expands the smtp.conf a bit as we are rerouting outgoing submissions to the dkim_out proxy, which will be resubmitted by the proxy for us to relay.

The dkim_out.conf looks like this:

listen    127.0.0.1:10027
relay     127.0.0.1:10028
domain    example.com
signature dkim(c=relaxed)
signature domainkeys(c=nofws)
keyfile   /etc/mail/dkim/private.key
selector  selector1

Generate a key using openssl:

openssl genrsa -out private.key 1024
openssl rsa -in private.key -pubout -out public.key

So the listen address will get outgoing mails IN and spits them back out properly signed.

So after doing this I got the following final smtp.conf file:

# tables
table aliases file:/etc/mail/aliases
table virtual-users file:/etc/mail/virtuals
table passwd passwd:/etc/mail/passwd

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

# To accept external mail, replace with: listen on all
listen on lo0
listen on lo0 port 10028 tag DKIM_OUT # passed through mails from dkim proxy
listen on egress port 25 tls pki mail.example.com
listen on egress port 587 tls-require pki mail.example.com auth <passwd>

accept tagged DKIM_OUT for any relay #signed mails just may leave the system

accept from local for local alias <aliases> deliver to lmtp "/var/dovecot/lmtp" rcpt-to
accept from any for domain "example.com" virtual <virtual-users> deliver to lmtp "/var/dovecot/lmtp" rcpt-to
accept from local for any relay via smtp://127.0.0.1:10027 # pass on to dkim proxy

So next step is to announce the DKIM public key in a DNS record. I have added also the spf and DMARC entries while at it. The zone file looks like this:

$ORIGIN example.com.
$TTL 1800
example.com. IN SOA ns1.digitalocean.com. hostmaster.example.com. 1467900139 10800 3600 604800 1800
example.com. 1800 IN NS ns1.digitalocean.com.
example.com. 1800 IN NS ns2.digitalocean.com.
example.com. 1800 IN NS ns3.digitalocean.com.
example.com. 1800 IN A 11.22.33.44
example.com. 1800 IN MX 10 mail.example.com.
mail.example.com. 1800 IN A 11.22.33.44
example.com. 1800 IN AAAA c0fe:babe:::::8001
mail.example.com. 1800 IN AAAA c0fe:babe:::::8001
selector1._domainkey.example.com. 1800 IN TXT k=rsa; t=s; p=MIGfMA0GCSqGb3DQEBAQUAA4GNADCBiQKBgQC0OWEGz4nMVGPdEzk6snF9rPu7cqFrip82nwcM1NqhLQdsUPjWJQacZV4B8er3E7HnCQu2NIw0DU0SuX9azvHkdWPPjyERwrbawt2mwCl2Ffxu169SshS8UDP7F0/U4dFoH40hEPsKCx2UyjNjk1MxBAvkYfJQ8WMA6wge+xWHKQID12df
example.com. 1800 IN TXT v=spf1 mx a -all
_dmarc.example.com. 1800 IN TXT v=DMARC1; p=none

I have found it very convenient to check these by sending mails to auth-results@verifier.port25.com.

Now I wanted to add some insult to injury for spammers so I have added spamd to the mix. For now I am sticking with the default config. The pf rules for this are taken verbatim from the pf.conf example.

The nospamd whitelist however needs some work as for example googlemail does not try hard enough with the same IP to get white-listed so one needs to add all addresses from their SPF records to the whitelist:

The quick and dirty way for me now has been:

# host -t txt _spf.google.com               
_spf.google.com descriptive text "v=spf1 include:_netblocks.google.com include:_netblocks2.google.com include:_netblocks3.google.com ~all"
# host -t txt _spf.google.com > ips
# host -t txt _netblocks.google.com >> ips
# host -t txt _netblocks2.google.com >> ips
# host -t txt _netblocks3.google.com >> ips
# tr ' ' '\n' < ips  > ips1 
# grep ^ip ips1 | sed 's,ip?:,,g' > ips

I have repeated that for ebay, amazon , web.de and gmx.de as those provide rather large lists for possible senders.

Also I have added the spamd-setup cron job and the stats cronjob as mentioned in the OpenSMTPd tutorial.

The only downside for now is that despite my mails being seen as legit by http://mail-tester.com and said port25.com my mails to gmail addresses still land in their spam folder. Maybe my test messages have been too short. I have filed an issue with their technical staff to sort out the issue. I do hope that it can be resolved.

That's it for now, I hope this will help you and my future self when rediscovering on how to set it all up.

Code on this site is licensed under a 2 clause BSD license, everything else unless noted otherwise is licensed under a CreativeCommonsAttribution-ShareAlike3.0UnportedLicense