Ansible Mailserver

Welcome to my simple mailserver setup via ansible.

This project is still work in progress, so maybe you will visit this site later on.

What we will setup:

  • Postfix SMTP Server with MySQL user authentication
  • Dovecot POP3/IMAP Server with MySQL user authentication
  • Amavis Virus Scanner with ClamAV and DKIM
  • Lets Encrypt Certbot for issuing SSL certificates

What we already have:

Inventory

So we need our inventory. I have a cheap Hetzner Cloud Server for this project (https://www.hetzner.com/cloud). This server has an internal and an external ip address and I have assigned the hostname "mail01.example.com".

hosts.yml:

---
all:
  hosts:
    mail01:
      ansible_host: 78.47.192.000
      private_ip: 10.0.10.3
      mail_hostname: mail01.example.com
  children:
    mailservers:
      hosts:
        mail01:

Group Variables

Our setup will support multiple mailservers we need to specify our group variables. Since we use an external database server, we need to specify these credentials. Also we want to sign our "mydomain.com" emails with DKIM and want to establish a secure connection over the alias "mail.mydomain.com".

group_vars/mailservers.yml:

---
mail_postmaster: postmaster@example.com
MYSQL_HOST: 10.0.10.6
MYSQL_USER: vimbadmin
MYSQL_PASSWORD: !!!CHANGE_ME!!!
MYSQL_DATABASE: vimbadmin
DKIM_DOMAINS:
  - mydomain.com
CERTBOT_EMAIL: certbot@example.com
ALIAS_DOMAINS:
  - mail.mydomain.com

Playbook

So we are setup so far and start writing our playbook. Since we complete rely on our role this playbook is very simple

mailservers.yml:

---
- hosts: mailservers
  remote_user: ansible
  become: yes
  vars_files:
    - group_vars/mailservers.yml
  roles:
     - mailserver

Writing our Role

So this segment is splitted into various parts. We need handlers, tasks and templates.

Handlers

So we use handlers to restart and enable our services. So they are only invoked when something is changed.

roles/mailserver/handlers/main.yml:

---
- name: restart dovecot
  service:
    name: dovecot
    state: restarted
    enabled: yes
- name: restart postfix
  service:
    name: postfix
    state: restarted
    enabled: yes
- name: restart amavis
  service:
    name: amavis
    state: restarted
    enabled: yes
- name: restart clamav-daemon
  service:
    name: clamav-daemon
    state: restarted
    enabled: yes
- name: restart clamav-freshclam
  service:
    name: clamav-freshclam
    state: restarted
    enabled: yes

Tasks

I've split the tasks in separate files so that the role will be simple to read:

Main

In the Main Tasks file we create the mail user and group for our virtual mailboxes. Then we include all other sub tasks.

roles/mailserver/tasks/main.yml:

---
- name: create vmail group
  group:
    name: vmail
    gid: 5000
    state: present
- name: create vmail user
  user:
    name: vmail
    group: vmail
    shell: /bin/bash
    append: yes
    home: /var/vmail/
    state: present
    uid: 5000
- name: setup dovecot
  import_tasks: dovecot.yml
- name: setup postfix
  import_tasks: postfix.yml
- name: setup amavis
  import_tasks: amavis.yml
- name: setup clamav
  import_tasks: clamav.yml
- name: setup certbot
  import_tasks: certbot.yml

Dovecot

Dovecot is our POP3/IMAP server and will handle the mail access for the users. We install all needed packages, create the general configuration file, the mysql configuration and diffie hellman parameters. The configuration files will be explained in the templates section of this document.

roles/mailserver/tasks/dovecot.yml:

---
- name: add dovecot repo key
  apt_key:
    url: https://repo.dovecot.org/DOVECOT-REPO-GPG
    state: present
- name: add dovecot repo
  apt_repository:
    repo: deb https://repo.dovecot.org/ce-2.3-latest/debian/{{ ansible_lsb["codename"] }} {{ ansible_lsb["codename"] }} main
    state: present
    filename: dovecot
    update_cache: yes
- name: update the repository cache and install dovecot packages
  apt:
    name: "{{ packages }}"
    state: latest
    update_cache: yes
  vars:
    packages:
    - dovecot-core
    - dovecot-imapd
    - dovecot-lmtpd
    - dovecot-managesieved
    - dovecot-mysql
    - dovecot-sieve
- name: copy dovecot config file
  template:
    src: dovecot/dovecot.j2
    dest: /etc/dovecot/dovecot.conf
  notify:
    - restart dovecot
- name: copy dovecot mysql config file
  template:
    src: dovecot/mysql.j2
    dest: /etc/dovecot/dovecot-mysql.conf
  notify:
    - restart dovecot
- name: create diffie hellman parameters (this takes a very long time)
  command: openssl dhparam -out /etc/ssl/dhparam4096.pem 4096
  args:
    creates: /etc/ssl/dhparam4096.pem
  notify:
    - restart dovecot

Postfix

To deliver our emails, we need postfix as an SMTP server. As with dovecot we copy the general config file and mysql config files for our virtual mailboxes.

roles/mailserver/tasks/postfix.yml:

---
- name: update the repository cache and install postfix packages
  apt:
    name: "{{ packages }}"
    state: latest
    update_cache: yes
  vars:
    packages:
    - postfix
    - postfix-mysql
    - postfix-pcre
    - postfix-sqlite
- name: copy postfix main config file
  template:
    src: postfix/main.j2
    dest: /etc/postfix/main.cf
  notify:
    - restart postfix
- name: copy postfix master config file
  template:
    src: postfix/master.j2
    dest: /etc/postfix/master.cf
  notify:
    - restart postfix
- name: create /etc/postfix/mysql/
  file:
    path: /etc/postfix/mysql/
    state: directory
    owner: root
    group: root
    mode: 0775
- name: copy postfix virtual alias maps config file
  template:
    src: postfix/postfix-mysql-virtual_alias_maps.j2
    dest: /etc/postfix/mysql/postfix-mysql-virtual_alias_maps.cf
  notify:
    - restart postfix
- name: copy postfix virtual domains maps config file
  template:
    src: postfix/postfix-mysql-virtual_domains_maps.j2
    dest: /etc/postfix/mysql/postfix-mysql-virtual_domains_maps.cf
  notify:
    - restart postfix
- name: copy postfix virtual mailbox maps config file
  template:
    src: postfix/postfix-mysql-virtual_mailbox_maps.j2
    dest: /etc/postfix/mysql/postfix-mysql-virtual_mailbox_maps.cf
  notify:
    - restart postfix
- name: copy postfix virtual transport maps config file
  template:
    src: postfix/postfix-mysql-virtual_transport_maps.j2
    dest: /etc/postfix/mysql/postfix-mysql-virtual_transport_maps.cf
  notify:
    - restart postfix

Amavis

To scan our emails for viruses and other malicious files we need an anti virus solution. Amavis uses ClamAV to achieve this goal. Also we use Spamassassin to analyze emails and block spam. A wide variety of DNS based block lists are used to help spam blocking.

roles/mailserver/tasks/amavis.yml:

---
- name: update the repository cache and install amavis packages
  apt:
    name: "{{ packages }}"
    state: latest
    update_cache: yes
  vars:
    packages:
    - amavisd-new
    - libdbi-perl
    - spamassassin
    - libdbd-mysql-perl
  notify:
    - restart amavis
- name: create amavis config file
  template:
    src: amavis/amavis.j2
    dest: /etc/amavis/conf.d/50-user
  notify:
    - restart amavis
- name: create dkim keys
  command: amavisd-new genrsa /var/lib/amavis/db/dkim_{{ item }}.key 2048
  args:
    creates: /var/lib/amavis/db/dkim_{{ item }}.key
  with_list: "{{ DKIM_DOMAINS }}"

ClamAV

As already mentioned, Amavis uses ClamAV to scan our mails after viruses, so we need to setup that tool too.

roles/mailserver/tasks/clamav.yml:

---
- name: Update the repository cache and install ClamAV packages
  apt:
    name: "{{ packages }}"
    state: latest
    update_cache: yes
  vars:
    packages:
    - clamav
    - clamav-base
    - clamav-daemon
    - clamav-freshclam
  notify:
    - restart clamav-daemon
    - restart clamav-freshclam
- name: Füge ClamAV User zur Amavis Gruppe hinzu
  user:
    name: clamav
    group: amavis
    append: yes
  notify:
    - restart clamav-daemon
    - restart clamav-freshclam
- name: Copy Clam Daemon Config File
  template:
    src: clamav/clamd.j2
    dest: /etc/clamav/clamd.conf
  notify:
    - restart clamav-daemon
- name: Copy Clam Freshclam Config File
  template:
    src: clamav/freshclam.j2
    dest: /etc/clamav/freshclam.conf
  notify:
    - restart clamav-freshclam

Lets Encrypt Certbot

To secure the client connections and send mails to TLS enabled mailservers we need valid ssl certificates. Lets Encrypt offers them for free and in a very automated way. Since we don't have a webserver installed, we use the standalone method.

As you may see we don't use the ansible module (because I don't like it), so there is a simple bash script which will be explained in the templates section.

roles/mailserver/tasks/certbot.yml:

---
- name: Update the repository cache and install certbot
  apt:
    name: certbot
    state: latest
    update_cache: yes
- name: Copy Create Certificate Script
  template:
    src: certbot/create-certificate.j2
    dest: /etc/letsencrypt/certificate.bash
    mode: 0755
- name: Install Certificates
  command: /etc/letsencrypt/certificate.bash
  args:
    creates: /etc/letsencrypt/live/{{ mail_hostname }}
- name: Create Renew Cronjob
  cron:
    name: "Renew Certificates"
    special_time: daily
    job: "certbot renew -n -q --agree-tos --email {{ CERTBOT_EMAIL }} --standalone && systemctl restart postfix dovecot"

Templates

In ansible we don't use config files, but templates which represents them.

Dovecot

For dovecot we use a general config file which includes the whole configuration for mailbox handling.

roles/mailserver/templates/dovecot/dovecot.j2:

# {{ ansible_managed }}
auth_mechanisms = plain login
listen = *
log_timestamp = "%Y-%m-%d %H:%M:%S "
login_log_format_elements = user= method=%m rip=%r lip=%l mpid=%e %c %k
mail_gid = vmail
mail_home = /var/vmail/%d/%n
mail_location = maildir:~/Maildir:LAYOUT=fs
mail_plugins = quota acl mail_log notify
mail_uid = vmail
managesieve_notify_capability = mailto
managesieve_sieve_capability = fileinto reject envelope encoded-character vacation subaddress comparator-i;ascii-numeric relational regex imap4flags copy include variables body enotify environment mailbox date index ihave duplicate mime foreverypart extracttext
namespace {
  list = yes
  location = maildir:%%h/Maildir:LAYOUT=fs:INDEXPVT=~/Maildir/Shared/%%u
  prefix = Shared/%%u/
  separator = /
  subscriptions = yes
  type = shared
}
namespace inbox {
  inbox = yes
  location =
  mailbox Archiv {
    special_use = \Archive
  }
  mailbox Archive {
    auto = subscribe
    special_use = \Archive
  }
  mailbox "Deleted Messages" {
    special_use = \Trash
  }
  mailbox Drafts {
    auto = subscribe
    special_use = \Drafts
  }
  mailbox Entwürfe {
    special_use = \Drafts
  }
  mailbox "Gelöschte Objekte" {
    special_use = \Trash
  }
  mailbox Gesendet {
    special_use = \Sent
  }
  mailbox Junk {
    auto = subscribe
    special_use = \Junk
  }
  mailbox Papierkorb {
    special_use = \Trash
  }
  mailbox Sent {
    auto = subscribe
    special_use = \Sent
  }
  mailbox "Sent Messages" {
    special_use = \Sent
  }
  mailbox Trash {
    auto = subscribe
    special_use = \Trash
  }
  prefix =
  separator = /
}
passdb {
  args = /etc/dovecot/dovecot-mysql.conf
  driver = sql
}
plugin {
  acl = vfile
  acl_anyone = allow
  acl_shared_dict = file:/var/vmail/shared-mailboxes.db
  mail_log_events = delete undelete expunge
  quota = maildir:User quota
  quota_rule = Sent:storage=+10%%
  quota_status_nouser = DUNNO
  quota_status_overquota = 552 5.2.2 Mailbox is over quota
  quota_status_success = DUNNO
  sieve = ~/sieve/dovecot.sieve
  sieve_before = /var/vmail/before.sieve
  sieve_dir = ~/sieve
  sieve_max_script_size = 1M
  sieve_quota_max_scripts = 0
  sieve_quota_max_storage = 0
}
protocols = imap sieve lmtp
service auth {
  unix_listener /var/spool/postfix/private/auth_dovecot {
    group = postfix
    mode = 0660
    user = postfix
  }
  unix_listener auth-master {
    mode = 0600
    user = vmail
  }
  unix_listener auth-userdb {
    mode = 0600
    user = vmail
  }
  user = root
}
service dict {
  unix_listener dict {
    group = dovecot
    mode = 0660
    user = vmail
  }
}
service lmtp {
  unix_listener /var/spool/postfix/private/dovecot-lmtp {
    group = postfix
    mode = 0600
    user = postfix
  }
  user = vmail
}
service managesieve-login {
  inet_listener sieve {
    address = {{ private_ip }}
    port = 4190
  }
  process_min_avail = 2
  service_count = 1
  vsz_limit = 128 M
}
service managesieve {
  process_limit = 256
}
service quota-status {
  client_limit = 1
  executable = quota-status -p postfix
  unix_listener /var/spool/postfix/private/quota-status {
    group = postfix
    mode = 0660
    user = postfix
  }
}
ssl_ca =  D_REJECT,
        final_bad_header_destiny => D_PASS,
        final_spam_destiny => D_PASS,
        terminate_dsn_on_notify_success => 0,
        warnbadhsender => 1,
};

# "mail.domain.tld" bitte anpassen
$myhostname = "{{ mail_hostname }}";

# Wer wird über Viren, Spam und "bad header mails" informiert?
# Den Benutzer "postmaster" bitte nachträglich in ViMbAdmin erstellen (Alias möglich)
$virus_admin = "postmaster\@$mydomain";
$spam_admin = "postmaster\@$mydomain";
$banned_quarantine_to = "postmaster\@$mydomain";
$bad_header_quarantine_to = "postmaster\@$mydomain";

# DKIM kann verifiziert werden.
$enable_dkim_verification = 1;

# AR-Header darf gesetzt werden
$allowed_added_header_fields{lc('Authentication-Results')} = 1;

# DKIM-Signatur
# Gilt nur, wenn "originating = 1", ergo für die SUBMISSION policy bank
# "default" ist hierbei der Selector
# "domain.tld" als Domäne bitte anpassen
# "enable_dkim_signing" nur "1" setzen, wenn Mails wirklich signiert werden sollen.
# "/var/lib/amavis/db/dkim_domain.tld.key" sollte ebenso dem Namen der Domäne angepasst werden.
# Die TTL beträgt im Beispiel 7 Tage
# relaxed/relaxed beschreibt die Header/Body canonicalization, relaxed ist weniger restriktiv

$enable_dkim_signing = 1;

# BEGIN AUTO GENERATED DKIM CONFIGURATION
{% for DOMAIN in DKIM_DOMAINS %}
dkim_key('{{ DOMAIN }}', 'default', '/var/lib/amavis/db/dkim_{{ DOMAIN }}.key');
{% endfor %}
# END AUTO GENERATED DKIM CONFIGURATION
@dkim_signature_options_bysender_maps = (
    { '.' =>
        {
                ttl => 7*24*3600,
                c => 'relaxed/relaxed'
        }
    }
);

# Viren- und Spamfilter ACL; werden automatisch ermittelt
@bypass_virus_checks_maps = (
   \%bypass_virus_checks, \@bypass_virus_checks_acl, \$bypass_virus_checks_re);

@bypass_spam_checks_maps = (
   \%bypass_spam_checks, \@bypass_spam_checks_acl, \$bypass_spam_checks_re);

#------------ Do not modify anything below this line -------------
1;  # ensure a defined return

ClamAV

For ClamAV we need to configure the ClamAV Daemon

roles/mailserver/templates/clamav/clamd.j2:

# {{ ansible_managed }}
#To reconfigure clamd run #dpkg-reconfigure clamav-daemon
LocalSocket /var/run/clamav/clamd.ctl
FixStaleSocket true
LocalSocketGroup amavis
LocalSocketMode 666
# TemporaryDirectory is not set to its default /tmp here to make overriding
# the default with environment variables TMPDIR/TMP/TEMP possible
User clamav
ScanMail true
ScanArchive true
ArchiveBlockEncrypted false
MaxDirectoryRecursion 15
FollowDirectorySymlinks false
FollowFileSymlinks false
ReadTimeout 180
MaxThreads 12
MaxConnectionQueueLength 15
LogSyslog false
LogRotate true
LogFacility LOG_LOCAL6
LogClean false
LogVerbose false
PreludeEnable no
PreludeAnalyzerName ClamAV
DatabaseDirectory /var/lib/clamav
OfficialDatabaseOnly false
SelfCheck 3600
Foreground false
Debug false
ScanPE true
MaxEmbeddedPE 10M
ScanOLE2 true
ScanPDF true
ScanHTML true
MaxHTMLNormalize 10M
MaxHTMLNoTags 2M
MaxScriptNormalize 5M
MaxZipTypeRcg 1M
ScanSWF true
ExitOnOOM false
LeaveTemporaryFiles false
AlgorithmicDetection true
ScanELF true
IdleTimeout 30
CrossFilesystems true
PhishingSignatures true
PhishingScanURLs true
PhishingAlwaysBlockSSLMismatch false
PhishingAlwaysBlockCloak false
PartitionIntersection false
DetectPUA false
ScanPartialMessages false
HeuristicScanPrecedence false
StructuredDataDetection false
CommandReadTimeout 30
SendBufTimeout 200
MaxQueue 100
ExtendedDetectionInfo true
OLE2BlockMacros false
ScanOnAccess false
AllowAllMatchScan true
ForceToDisk false
DisableCertCheck false
DisableCache false
MaxScanTime 120000
MaxScanSize 100M
MaxFileSize 25M
MaxRecursion 16
MaxFiles 10000
MaxPartitions 50
MaxIconsPE 100
PCREMatchLimit 10000
PCRERecMatchLimit 5000
PCREMaxFileSize 25M
ScanXMLDOCS true
ScanHWP3 true
MaxRecHWP3 16
StreamMaxLength 25M
LogFile /var/log/clamav/clamav.log
LogTime true
LogFileUnlock false
LogFileMaxSize 0
Bytecode true
BytecodeSecurity TrustSigned
BytecodeTimeout 60000

and Freshclam to get updated virus databases

roles/mailserver/templates/clamav/freshclam.j2:

# {{ ansible_managed }}
DatabaseOwner clamav
UpdateLogFile /var/log/clamav/freshclam.log
LogVerbose false
LogSyslog false
LogFacility LOG_LOCAL6
LogFileMaxSize 0
LogRotate true
LogTime true
Foreground false
Debug false
MaxAttempts 5
DatabaseDirectory /var/lib/clamav
DNSDatabaseInfo current.cvd.clamav.net
ConnectTimeout 30
ReceiveTimeout 30
TestDatabases yes
ScriptedUpdates yes
CompressLocalDatabase no
SafeBrowsing false
Bytecode true
NotifyClamd /etc/clamav/clamd.conf
# Check for new database 24 times a day
Checks 24
DatabaseMirror db.local.clamav.net
DatabaseMirror database.clamav.net

Certbot

Certbot initializes it's configuration on first run, so we use this template to generate a bash script which we use to issue a certificate with all of our alias domains included.

roles/mailserver/templates/certbot/create-certificate.j2:

#!/usr/bin/env bash
certbot certonly \
        -d {{ mail_hostname }} \
        --cert-name {{ mail_hostname }} \
        {% if ALIAS_DOMAINS is defined %}
        {% for DOMAIN in ALIAS_DOMAINS %}
        -d {{ DOMAIN }} \
        {% endfor %}
        {% endif %}
        -n \
        --agree-tos \
        --standalone \
        --email {{ CERTBOT_EMAIL }}

Execution

So our whole role is finished. Now we need to execute our playbook:

ansible-playbook mailservers.yml