Shami's Blog

Sysadmin, Because Even Developers Need Heroes

HOWTO: Small Mail Server With Salt, Dovecot, And OpenSMTPD

2015-01-26 by Mohammad H. Al-Shami

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

pkg install dovecot2 opensmtpd py27-salt dkimproxy

Enable Salt masterless mode if you need to, otherwise configure Salt as you normally do

cd /usr/local/etc/salt
cp minion.sample minion

Change the file_client to local

file_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:

import crypt
import os

def hash(pw, salt=None):
	if not salt:
		salt = os.urandom(16).encode('base_64')

	salt = "$6${}$".format(salt)

	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:

def generate(bits=1024):
	'''
	Generate an RSA keypair with an exponent of 65537 in PEM format
	param: bits The key length in bits
	Return private key and public key
	'''
	
	from Crypto.PublicKey import RSA
	new_key = RSA.generate(bits, e=65537)

	public_key = str(new_key.publickey().exportKey("PEM"))
	public_key = public_key.replace('-----BEGIN PUBLIC KEY-----', '')
	public_key = public_key.replace('-----END PUBLIC KEY-----', '')
	public_key = public_key.replace('\n', '')

	private_key = str(new_key.exportKey("PEM"))

	return [private_key, public_key]

Pillar files: Here we store all domains, accounts and passwords

top.sls:

base:
  '*':
    - mail.users

Here, salt is the password salt used to encrypt the passwords. It’s not related to the configuration manager

mail/users.sls:

salt: aoiuasdfhalsdfiuyh

domains:
  - example.com
  - test.com

accounts:
  - 
    - [email protected]
    - pass1
  -
    - [email protected]
    - pass2
  -
    - [email protected]
    - pass3

State files: Configure Dovecot, openSMTPD and DKIMProxy

top.sls:

base:
  '*':
    - mailsrv.genkeys
    - mailsrv.opensmtpd
    - mailsrv.dovecot
    - mailsrv.dkimproxy

Dovecot:

mailsrv/dovecot.sls:

dovecot:
  service:
    - running
    - reload: True
    - watch:
      - file: /usr/local/etc/dovecot/dovecot-passwd
      - file: /usr/local/etc/dovecot/dovecot.conf

/usr/local/etc/dovecot:
  file.directory:
    - makedirs: True

/usr/local/etc/dovecot/dovecot-passwd:
  file.managed:
    - source: salt://mailsrv/conf/dovecot/dovecot-passwd
    - template: jinja
    - require:
      - file: /usr/local/etc/dovecot

/usr/local/etc/dovecot/dovecot.conf:
  file.managed:
    - source: salt://mailsrv/conf/dovecot/dovecot.conf
    - require:
      - file: /usr/local/etc/dovecot

mailsrv/conf/dovecot/dovecot.conf:

protocols = imap pop3 lmtp

log_path = /var/log/dovecot.log

# SSL configuration
ssl = yes
# Preferred permissions: root:root 0444
ssl_cert = </usr/local/etc/mail/cert/cert
# Preferred permissions: root:root 0400
ssl_key = </usr/local/etc/mail/cert/key

mail_location = mdbox:~/mdbox

passdb {
	driver = passwd-file
	args = /usr/local/etc/dovecot/dovecot-passwd
}

userdb {
	driver = static
	args = uid=vmail gid=vmail home=/vmail/%d/%n
}

service lmtp {
	inet_listener lmtp {
		address = 127.0.0.1
		port = 2525
	}

	#This is here to handle high traffic
	process_min_avail = 10
}

#Private name space, allows each user to access their mailbox
namespace {
  type = private
  separator = /
  prefix =
  location =
  inbox = yes
}

mailsrv/conf/dovecot/dovecot-passwd:

{% raw %}{% set passwordSalt = salt['pillar.get']('salt') %}
{%- for account in salt['pillar.get']('accounts') -%}
{{ account[0] }}:{SHA512-CRYPT}{{ salt['password.hash'](account[1], passwordSalt) }}
{% endfor -%}{% endraw %}

OpenSMTPD:

mailsrv/opensmtpd.sls

smtpd:
  service:
    - running
    - watch:
      - file: /usr/local/etc/mail/vdomains
      - file: /usr/local/etc/mail/recipients
      - file: /usr/local/etc/mail/passwd
      - file: /usr/local/etc/mail/smtpd.conf
      - file: /usr/local/etc/mail/cert

/usr/local/etc/mail:
  file.directory:
    - makedirs: True

/usr/local/etc/mail/vdomains:
  file.managed:
    - source: salt://mailsrv/conf/opensmtpd/vdomains
    - template: jinja
    - require:
      - file: /usr/local/etc/mail

/usr/local/etc/mail/recipients:
  file.managed:
    - source: salt://mailsrv/conf/opensmtpd/recipients
    - template: jinja
    - require:
      - file: /usr/local/etc/mail

/usr/local/etc/mail/passwd:
  file.managed:
    - source: salt://mailsrv/conf/opensmtpd/passwd
    - template: jinja
    - require:
      - file: /usr/local/etc/mail

/usr/local/etc/mail/smtpd.conf:
  file.managed:
    - source: salt://mailsrv/conf/opensmtpd/smtpd.conf
    - require:
      - file: /usr/local/etc/mail

/usr/local/etc/mail/cert:
  file.recurse:
    - source: salt://mailsrv/conf/opensmtpd/cert
    - user: root
    - group: wheel
    - file_mode: 600
    - dir_mode: 700
    - require:
      - 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:

#PKI file locations
pki mailsrv.example.com certificate "/usr/local/etc/mail/cert/cert"
pki mailsrv.example.com key "/usr/local/etc/mail/cert/key"

# Accept email for these domains and recipients
table vdomains "file:/usr/local/etc/mail/vdomains"
table recipients "file:/usr/local/etc/mail/recipients"

# If you edit the file, you have to run "smtpctl update table aliases"
table aliases file:/etc/mail/aliases

# File where encrypted passwords are stored
table local_user_list passwd:/usr/local/etc/mail/passwd

# Listen for user logins on submission port
listen on 0.0.0.0 port submission tls pki mailsrv.example.com auth <local_user_list> hostname "mailsrv.example.com"

# To accept external mail
listen on 0.0.0.0

# Accept signed emails
listen on localhost port 10028 tag DKIM mask-source

# Forward incoming emails to Dovecot via LMTP
accept from any for domain <vdomains> recipient <recipients> relay via lmtp://127.0.0.1:2525

# Forward emails to local accounts to their MBOXs
accept for local alias <aliases> deliver to mbox

# Relay signed emails
accept tagged DKIM for any relay

# If an email was sent locally or through an authenticated user, sign
accept 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:

{% raw %}{% set passwordSalt = salt['pillar.get']('salt') %}
{%- set accounts = salt['pillar.get']('accounts') %}
{%- for account in accounts[:-1] -%}
{{ account[0] }}:{{ salt['password.hash'](account[1], passwordSalt) }}:1001:1001::/vmail:/bin/nologin
{% endfor -%}
{{ accounts[-1][0] -}}
	:{{
	salt['password.hash'](
		accounts[-1][1], passwordSalt)
	-}}
:1001:1001::/vmail:/bin/nologin{% endraw %}

mailsrv/conf/opensmtpd/recipients:

{% raw %}{% for account in salt['pillar.get']('accounts') -%}
{{ account[0] }}
{% endfor -%}{% endraw %}

mailsrv/conf/opensmtpd/vdomains:

{% raw %}{% for domain in salt['pillar.get']('domains') -%}
{{ domain }}
{% endfor -%}{% endraw %}

DKIMProxy:

mailsrv/dkimproxy.sls:

dkimproxy_out:
  service:
    - running
    - enable: True
    - watch:
      - file: /usr/local/etc/dkimproxy/keyfiles
      - file: /usr/local/etc/dkimproxy_out.conf

/usr/local/etc/dkimproxy:
  file.directory:
    - makedirs: True

/usr/local/etc/dkimproxy/keyfiles:
  file.managed:
    - source: salt://mailsrv/conf/dkimproxy/keyfiles
    - template: jinja
    - require:
      - file: /usr/local/etc/dkimproxy

/usr/local/etc/dkimproxy_out.conf:
  file.managed:
    - source: salt://mailsrv/conf/dkimproxy_out.conf
    - user: dkimproxy
    - group: dkimproxy
    - file_mode: 640
    - require:
      - file: /usr/local/etc/dkimproxy

mailsrv/genkeys.sls:

{% raw %}{% for domain in salt['pillar.get']('domains') %}

{% set keys = salt['dkim.generate']() %}

{# Only generate keys for domains with missing files #}
{% if 1 == salt['cmd.retcode']('test -f /usr/local/etc/dkimproxy/' ~ domain ~ '/private') %}

/usr/local/etc/dkimproxy/{{ domain }}:
  file.directory:
    - makedirs: True

/usr/local/etc/dkimproxy/{{ domain }}/private:
  file.managed:
    - source: salt://mailsrv/conf/dkimproxy/domain/private
    - template: jinja
    - require:
      - file: /usr/local/etc/dkimproxy/{{ domain }}
    - context:
      keys: {{ keys }}
    - watch_in:
      - service: dkimproxy_out

/usr/local/etc/dkimproxy/{{ domain }}/public:
  file.managed:
    - source: salt://mailsrv/conf/dkimproxy/domain/public
    - template: jinja
    - require:
      - file: /usr/local/etc/dkimproxy/{{ domain }}
    - context:
      keys: {{ keys }}
      domain: {{ domain }}
    - require:
      - file: /usr/local/etc/dkimproxy/{{ domain }}/private

{% endif %}
{% endfor %}{% endraw %}

mailsrv/conf/dkimproxy_out.conf:

# specify what address/port DKIMproxy should listen on
listen    127.0.0.1:10027

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

sender_map /usr/local/etc/dkimproxy/keyfiles

# control how many processes DKIMproxy uses
#  - more information on these options (and others) can be found by
#    running `perldoc Net::Server::PreFork'.
min_servers   10
max_servers   40

min_spare_servers 5

mailsrv/conf/dkimproxy/keyfiles:

{% raw %}{% set domains = salt['pillar.get']('domains') %}
{%- for domain in domains[:-1] -%}
{{ domain }} dkim(c=relaxed/simple, a=rsa-sha256,s=mailsrv,key=/usr/local/etc/dkimproxy/{{ domain }}/private)
{% 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:

{% raw %}mailsrv._domainkey	IN	TXT	( "v=DKIM1; k=rsa; t=s; "
	  "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

salt-call state.highstate
# or
salt 'mailsrv' state.highstate

Just a note, when the first client authenticates with OpenSMTPD after a restart, the log file will show the following error none Authentication temporarily failed for user [email protected]

That’s OK, it will switch to the virtual user file afterwards. Not sure if this is a bug or a feature


comment: