HOWTO: Small Mail Server With Salt, Dovecot, And OpenSMTPD
Update: Sadly OpenSMTPD version 5.4.4 on FreeBSD broke the passwd table, I’m checking the Gills to get this fixed.
Update2: I am no longer using OpenSMTPD. I’ve switched to dma for servers that only need to send emails and went back to Postfix for servers that require an actual MTA. Not that OpenSMTPD is bad, I just prefer Postfix. I might reconcider in the future when OpenSMTPD is more mature.
I’m a big fan of Postfix and have been using it for years, but also find it to be an overkill for some of my servers. I don’t want to have to install Postfix on my DNS management server just to send change notifications. Sendmail was good for that for a while, but I hated the configuration language. Then I read that the OpenBSD maintainers switched to OpenSMTPD as their default MTA, so I decided to give it a shot.
It turned out to be a very nice piece of software; Small, fast, stable, and very easy to customize, no more ugly m4 macros to deal with :D. Now I have a Salt formula that installs OpenSMTPD, configures it to auto-start, and disables Sendmail. I use that for all non-mail servers for report and notification emails.
Then I decided to start using OpenSMTPD on some of our smaller mail servers; We have a small PHPList server so I decided to start with that.
This configuration is targeted towards smaller mail servers, if you have a few accounts you can host this configuration on a small VPS. You don’t have to use a Salt master, you can simply use a masterless setup. Why Salt? Because the configuration uses static files to store usernames and passwords but OpenSMTPD and Dovecot can’t share those files. You’re welcome to maintain those files by hand, but I think it’s too much of a hassle.
I use FreeBSD so the configuration files reflect that, change to fit your own setup.
First, install the needed software
1pkg install dovecot2 opensmtpd py27-salt dkimproxy
Enable Salt masterless mode if you need to, otherwise configure Salt as you normally do
1cd /usr/local/etc/salt
2cp minion.sample minion
Change the file_client to local
1file_client: local
Now lets get to the configuration files. Full configuration is hosted on GitHub
Custom Salt modules: Copy to states/_modules inside your salt directory: This module hashes the given plain text passwords, and uses a random password salt if none was provided
password.py:
1import crypt
2import os
3
4def hash(pw, salt=None):
5 if not salt:
6 salt = os.urandom(16).encode('base_64')
7
8 salt = "$6${}$".format(salt)
9
10 return crypt.crypt(str(pw), salt)
This module generates the private key and the zone file entry to be used with BIND/NSD for the DKIM key
dkim.py:
1def generate(bits=1024):
2 '''
3 Generate an RSA keypair with an exponent of 65537 in PEM format
4 param: bits The key length in bits
5 Return private key and public key
6 '''
7
8 from Crypto.PublicKey import RSA
9 new_key = RSA.generate(bits, e=65537)
10
11 public_key = str(new_key.publickey().exportKey("PEM"))
12 public_key = public_key.replace('-----BEGIN PUBLIC KEY-----', '')
13 public_key = public_key.replace('-----END PUBLIC KEY-----', '')
14 public_key = public_key.replace('\n', '')
15
16 private_key = str(new_key.exportKey("PEM"))
17
18 return [private_key, public_key]
Pillar files: Here we store all domains, accounts and passwords
top.sls:
1base:
2 '*':
3 - mail.users
Here, salt is the password salt used to encrypt the passwords. It’s not related to the configuration manager
mail/users.sls:
1salt: aoiuasdfhalsdfiuyh
2
3domains:
4 - example.com
5 - test.com
6
7accounts:
8 -
9 - [email protected]
10 - pass1
11 -
12 - [email protected]
13 - pass2
14 -
15 - [email protected]
16 - pass3
State files: Configure Dovecot, openSMTPD and DKIMProxy
top.sls:
1base:
2 '*':
3 - mailsrv.genkeys
4 - mailsrv.opensmtpd
5 - mailsrv.dovecot
6 - mailsrv.dkimproxy
Dovecot:
mailsrv/dovecot.sls:
1dovecot:
2 service:
3 - running
4 - reload: True
5 - watch:
6 - file: /usr/local/etc/dovecot/dovecot-passwd
7 - file: /usr/local/etc/dovecot/dovecot.conf
8
9/usr/local/etc/dovecot:
10 file.directory:
11 - makedirs: True
12
13/usr/local/etc/dovecot/dovecot-passwd:
14 file.managed:
15 - source: salt://mailsrv/conf/dovecot/dovecot-passwd
16 - template: jinja
17 - require:
18 - file: /usr/local/etc/dovecot
19
20/usr/local/etc/dovecot/dovecot.conf:
21 file.managed:
22 - source: salt://mailsrv/conf/dovecot/dovecot.conf
23 - require:
24 - file: /usr/local/etc/dovecot
mailsrv/conf/dovecot/dovecot.conf:
1protocols = imap pop3 lmtp
2
3log_path = /var/log/dovecot.log
4
5# SSL configuration
6ssl = yes
7# Preferred permissions: root:root 0444
8ssl_cert = </usr/local/etc/mail/cert/cert
9# Preferred permissions: root:root 0400
10ssl_key = </usr/local/etc/mail/cert/key
11
12mail_location = mdbox:~/mdbox
13
14passdb {
15 driver = passwd-file
16 args = /usr/local/etc/dovecot/dovecot-passwd
17}
18
19userdb {
20 driver = static
21 args = uid=vmail gid=vmail home=/vmail/%d/%n
22}
23
24service lmtp {
25 inet_listener lmtp {
26 address = 127.0.0.1
27 port = 2525
28 }
29
30 #This is here to handle high traffic
31 process_min_avail = 10
32}
33
34#Private name space, allows each user to access their mailbox
35namespace {
36 type = private
37 separator = /
38 prefix =
39 location =
40 inbox = yes
41}
mailsrv/conf/dovecot/dovecot-passwd:
1{% raw %}{% set passwordSalt = salt['pillar.get']('salt') %}
2{%- for account in salt['pillar.get']('accounts') -%}
3{{ account[0] }}:{SHA512-CRYPT}{{ salt['password.hash'](account[1], passwordSalt) }}
4{% endfor -%}{% endraw %}
OpenSMTPD:
mailsrv/opensmtpd.sls
1smtpd:
2 service:
3 - running
4 - watch:
5 - file: /usr/local/etc/mail/vdomains
6 - file: /usr/local/etc/mail/recipients
7 - file: /usr/local/etc/mail/passwd
8 - file: /usr/local/etc/mail/smtpd.conf
9 - file: /usr/local/etc/mail/cert
10
11/usr/local/etc/mail:
12 file.directory:
13 - makedirs: True
14
15/usr/local/etc/mail/vdomains:
16 file.managed:
17 - source: salt://mailsrv/conf/opensmtpd/vdomains
18 - template: jinja
19 - require:
20 - file: /usr/local/etc/mail
21
22/usr/local/etc/mail/recipients:
23 file.managed:
24 - source: salt://mailsrv/conf/opensmtpd/recipients
25 - template: jinja
26 - require:
27 - file: /usr/local/etc/mail
28
29/usr/local/etc/mail/passwd:
30 file.managed:
31 - source: salt://mailsrv/conf/opensmtpd/passwd
32 - template: jinja
33 - require:
34 - file: /usr/local/etc/mail
35
36/usr/local/etc/mail/smtpd.conf:
37 file.managed:
38 - source: salt://mailsrv/conf/opensmtpd/smtpd.conf
39 - require:
40 - file: /usr/local/etc/mail
41
42/usr/local/etc/mail/cert:
43 file.recurse:
44 - source: salt://mailsrv/conf/opensmtpd/cert
45 - user: root
46 - group: wheel
47 - file_mode: 600
48 - dir_mode: 700
49 - require:
50 - file: /usr/local/etc/mail
Use relay instead of deliver because OpenSMTPD requires a local user account to deliver, this way we can use virtual accounts with Dovecot
mailsrv/conf/opensmtpd/smtpd.conf:
1#PKI file locations
2pki mailsrv.example.com certificate "/usr/local/etc/mail/cert/cert"
3pki mailsrv.example.com key "/usr/local/etc/mail/cert/key"
4
5# Accept email for these domains and recipients
6table vdomains "file:/usr/local/etc/mail/vdomains"
7table recipients "file:/usr/local/etc/mail/recipients"
8
9# If you edit the file, you have to run "smtpctl update table aliases"
10table aliases file:/etc/mail/aliases
11
12# File where encrypted passwords are stored
13table local_user_list passwd:/usr/local/etc/mail/passwd
14
15# Listen for user logins on submission port
16listen on 0.0.0.0 port submission tls pki mailsrv.example.com auth <local_user_list> hostname "mailsrv.example.com"
17
18# To accept external mail
19listen on 0.0.0.0
20
21# Accept signed emails
22listen on localhost port 10028 tag DKIM mask-source
23
24# Forward incoming emails to Dovecot via LMTP
25accept from any for domain <vdomains> recipient <recipients> relay via lmtp://127.0.0.1:2525
26
27# Forward emails to local accounts to their MBOXs
28accept for local alias <aliases> deliver to mbox
29
30# Relay signed emails
31accept tagged DKIM for any relay
32
33# If an email was sent locally or through an authenticated user, sign
34accept for any relay via smtp://127.0.0.1:10027
For passwd, I had to write it this way because openSMTPD at the time of writing shuts down if it finds an empty line in the passwd file, if I just looped through the accounts hash the file would have an extra blank line at the end Update: Gilles is looking into this
mailsrv/conf/opensmtpd/passwd:
1{% raw %}{% set passwordSalt = salt['pillar.get']('salt') %}
2{%- set accounts = salt['pillar.get']('accounts') %}
3{%- for account in accounts[:-1] -%}
4{{ account[0] }}:{{ salt['password.hash'](account[1], passwordSalt) }}:1001:1001::/vmail:/bin/nologin
5{% endfor -%}
6{{ accounts[-1][0] -}}
7 :{{
8 salt['password.hash'](
9 accounts[-1][1], passwordSalt)
10 -}}
11:1001:1001::/vmail:/bin/nologin{% endraw %}
mailsrv/conf/opensmtpd/recipients:
1{% raw %}{% for account in salt['pillar.get']('accounts') -%}
2{{ account[0] }}
3{% endfor -%}{% endraw %}
mailsrv/conf/opensmtpd/vdomains:
1{% raw %}{% for domain in salt['pillar.get']('domains') -%}
2{{ domain }}
3{% endfor -%}{% endraw %}
DKIMProxy:
mailsrv/dkimproxy.sls:
1dkimproxy_out:
2 service:
3 - running
4 - enable: True
5 - watch:
6 - file: /usr/local/etc/dkimproxy/keyfiles
7 - file: /usr/local/etc/dkimproxy_out.conf
8
9/usr/local/etc/dkimproxy:
10 file.directory:
11 - makedirs: True
12
13/usr/local/etc/dkimproxy/keyfiles:
14 file.managed:
15 - source: salt://mailsrv/conf/dkimproxy/keyfiles
16 - template: jinja
17 - require:
18 - file: /usr/local/etc/dkimproxy
19
20/usr/local/etc/dkimproxy_out.conf:
21 file.managed:
22 - source: salt://mailsrv/conf/dkimproxy_out.conf
23 - user: dkimproxy
24 - group: dkimproxy
25 - file_mode: 640
26 - require:
27 - file: /usr/local/etc/dkimproxy
mailsrv/genkeys.sls:
1{% raw %}{% for domain in salt['pillar.get']('domains') %}
2
3{% set keys = salt['dkim.generate']() %}
4
5{# Only generate keys for domains with missing files #}
6{% if 1 == salt['cmd.retcode']('test -f /usr/local/etc/dkimproxy/' ~ domain ~ '/private') %}
7
8/usr/local/etc/dkimproxy/{{ domain }}:
9 file.directory:
10 - makedirs: True
11
12/usr/local/etc/dkimproxy/{{ domain }}/private:
13 file.managed:
14 - source: salt://mailsrv/conf/dkimproxy/domain/private
15 - template: jinja
16 - require:
17 - file: /usr/local/etc/dkimproxy/{{ domain }}
18 - context:
19 keys: {{ keys }}
20 - watch_in:
21 - service: dkimproxy_out
22
23/usr/local/etc/dkimproxy/{{ domain }}/public:
24 file.managed:
25 - source: salt://mailsrv/conf/dkimproxy/domain/public
26 - template: jinja
27 - require:
28 - file: /usr/local/etc/dkimproxy/{{ domain }}
29 - context:
30 keys: {{ keys }}
31 domain: {{ domain }}
32 - require:
33 - file: /usr/local/etc/dkimproxy/{{ domain }}/private
34
35{% endif %}
36{% endfor %}{% endraw %}
mailsrv/conf/dkimproxy_out.conf:
1# specify what address/port DKIMproxy should listen on
2listen 127.0.0.1:10027
3
4# specify what address/port DKIMproxy forwards mail to
5relay 127.0.0.1:10028
6
7sender_map /usr/local/etc/dkimproxy/keyfiles
8
9# control how many processes DKIMproxy uses
10# - more information on these options (and others) can be found by
11# running `perldoc Net::Server::PreFork'.
12min_servers 10
13max_servers 40
14
15min_spare_servers 5
mailsrv/conf/dkimproxy/keyfiles:
1{% raw %}{% set domains = salt['pillar.get']('domains') %}
2{%- for domain in domains[:-1] -%}
3{{ domain }} dkim(c=relaxed/simple, a=rsa-sha256,s=mailsrv,key=/usr/local/etc/dkimproxy/{{ domain }}/private)
4{% endfor -%}{{ domains[-1] }} dkim(c=relaxed/simple, a=rsa-sha256,s=mailsrv,key=/usr/local/etc/dkimproxy/{{ domains[-1] }}/private){% endraw %}
mailsrv/conf/dkimproxy/domain/public:
1{% raw %}mailsrv._domainkey IN TXT ( "v=DKIM1; k=rsa; t=s; "
2 "p= {{ keys[1] }}" ) ; ----- DKIM key mailsrv for {{ domain }}{% endraw %}
Just make sure to place a valid certificate and key in states/mailsrv/conf/opensmtpd/cert and you should be good to go after running
1salt-call state.highstate
2# or
3salt 'mailsrv' state.highstate
Just a note, when the first client authenticates with OpenSMTPD after a restart, the log file will show the following error
1Authentication temporarily failed for user user@domain
That’s OK, it will switch to the virtual user file afterwards. Not sure if this is a bug or a feature
About Me
Dev gone Ops gone DevOps. Any views expressed on this blog are mine alone and do not necessarily reflect the views of my employer.
Recent Posts