K.S. Root

Create an Exim to SES Pipeline with Ansible

Why

Often times you need to perform some additional, potentially sophisticated, processing on out-going emails before they are sent out.

This doesn't mean you need to run your own MTA for all out-bound email. It might be the case that only some portion of your total out-bound email might need some operation performed on it. Here are a few examples that spring to mind:

  • Restricting out-bound emails to a limited number of explicitly allowed domains or addresses (useful for test or development environments).
  • Automatically applying a confidentiality notice to emails destined to leave an organization.
  • Preventing sensitive data leakage.

In these cases it can still make sense to use SES while running your own MTA(s) internally.

Defining the configuration

Amazon already has a guide on integrating SES with Exim. Really there are only a handful of steps, so the increased effort involved in capturing this procedure as an Ansible role is barely significant compared to simply running through the procedure manually. Though we gain the advantages that come with using a configuration management tool from the get-go.

I'll assume we are running on a clone or derivative of RHEL (like CentOS, Scientific Linux, Amazon Linux, etc.). Later on I plan on writing a follow-up post on how we can generalize this role to Debian-based distributions as well.

Exim

First, we need to install EPEL since Red Hat does not package Exim with their relatively small set of supported packages. Let's use a pre-written and battle-tested role from the Ansible Galaxy to install it.

$ ansible-galaxy install goozbach.EPEL

Create the scaffolding for a new Ansible role. This can either be done manually with mkdir or automatically via ansible-galaxy. Either way, try to adhere the recommended layout.

$ mkdir -p exim-to-ses/{tasks,templates,handlers,defaults,files}
$ ansible-galaxy init exim-to-ses

In the main.yml file in tasks/ we can install Exim and the other dependencies we will need to complete the pipeline.

- name: install pkgs
  yum: name={{ item }} state=installed
  with_items:
    - exim
    # required by the ipaddr() filter.
    - python-netaddr
    - stunnel
    - xinetd

Let's copy the default configuration to a safe location where we won't overwrite it. It could potentially be useful as a reference later on down the line.

- name: move the default exim.conf someplace safe
  command: >
    mv /etc/exim/exim.conf /etc/exim/exim.conf.orig 
    creates=/etc/exim/exim.conf.orig

The template for exim.conf (exim.conf.j2) is the same as the default Exim configuration that ships with the package from EPEL, however it contains the custom router, transport, and authenticator describe in the integration guide. It also attempts to set relay_from_hosts.

- name: create exim.conf
  template: src=exim.conf.j2 dest=/etc/exim/exim.conf
  notify: exim service

The template also sets the value of relay_from_hosts. If the Ansible variable (not the Exim configuration directive of the same name) relay_from_hosts is set, then that set value will be passed into the template. If the variable is left undefined we calculate the CIDR representation of the subnet of the default network interface and use that value instead.

domainlist local_domains = @ : localhost : localhost.localdomain
domainlist relay_to_domains = 
{% set net_mask = [ ansible_default_ipv4['network'], "/", ansible_default_ipv4['netmask'] ] %}
{% set default_relay_from_hosts = net_mask | join | ipaddr('net') %}
hostlist   relay_from_hosts = {{ relay_from_hosts | default(default_relay_from_hosts) }}

The ses_login authenticator contains variables which will be filled by the template task when the role is run.

ses_login:
driver = plaintext
public_name = LOGIN
client_send = : {{ aws_ses_username }} : {{ aws_ses_password }}

Finally, let's ensure we actually start Exim by defining the handler referenced above.

- name: exim service
  service: name=exim enabled=yes state=started

With that our Exim configuration is complete. Don't worry if you are confused by my piece-by-piece description of the template. The file is simply too long to be shown in-line, I'll provide a link to the entire role further down. It's only necessary to understand what parts of the Exim configuration file are being modified and why.

stunnel & xinetd

The transport we defined in the exim.conf.j2 template expects to be able to send out-bound mail to localhost:2525. This is by design. We will next configure stunnel to create a secure connection between localhost:2525 and the SES SMTP endpoint.

- name: create stunnel.conf
  template: src=stunnel.conf.j2 dest=/etc/stunnel/stunnel.conf

The stunnel.conf.j2 template is very short, there is only a single variable to fill.

client = yes
connect = email-smtp.{{ aws_region }}.amazonaws.com:465
delay = yes
sslVersion = TLSv1

Set a default value for aws_region inside the defaults/main.yml file like this. It should be whatever region you're most likely to use.

---
aws_region: us-east-1

The guide ends with the user invoking stunnel directly. My trust in stunnel's ability not to fall over is… not very great, so I have instead configured xinetd to create new stunnel processes on demand.

- name: copy stunnel xinted service file into place
  copy: src=stunnel dest=/etc/xinetd.d/stunnel
  notify: xinetd service

This will create some “unnecessary” overhead since there will be a listener process kicking off other processes that actually handle requests, instead of a single process handling everything. Though we will not need to worry about a single stunnel process potentially dying. This approach also does not require we rely on any systemd functionality to automatically restart the stunnel process.

Now finish the role by defining the xinetd service handler.

- name: xinetd service
  service: name=xinetd enabled=yes state=started

Tying it all together

I've uploaded the entire role to the Ansible Galaxy as a reference. The final task remaining is to create a playbook which demonstrates our new role.

We defined the following Ansible variables:

aws_region
aws_ses_username
aws_ses_password
relay_from_hosts

aws_region and relay_from_hosts will both receive default values if they are left undefined, however aws_ses_username and aws_ses_password must have values explicitly defined.

A playbook might look like this. Replace localhost with the name of the host you want to target and set aws_ses_username and aws_ses_password.

---
- hosts: localhost
  become: yes
  roles:
    - goozbach.EPEL
    - { role: kyleroot.exim-to-ses, aws_ses_username: USERNAME, aws_ses_password: PASSWORD }

Or, since aws_ses_username and aws_ses_password are sensitive values that you would not want to check into version control in plain text it might more make sense to include a vars file encrypted with ansible-vault.

---
- hosts: localhost
  become: yes
  vars_files:
    # encrypted with ansible-vault.
    - "vars/aws_ses.yml"
  roles:
    - goozbach.EPEL
    - kyleroot.exim-to-ses