Instalación completa de Dbmail con MTA y antispam con Ansible y a lo bestia

imagen de naves bombardeando un planeta

Como ya indiqué en este artículo, me he embarcado en un proyecto mastodóntico - con afán de aprender sobre todo - de crear una plataforma de productividad basada en soluciones de software libre, sí ya sé que hay muchas pero ninguna me convence, y como gestor de correo había elegido dbmail. DBmail es un producto maduro, moderadamente bien documentado (la documentación es muy mejorable, pero si la comparas con la de los MTAs es una maravilla) y que le da a los servidores de correo un enfoque distinto, no llega a los límites de mi querido y nunca suficientemente valorado Manitou Mail, pero cambia el sistema clásico de almacenamiento por un sistema de base de datos.

Ya que me estoy volviendo un loco de Ansible, vamos a utilizar como referencia esta mágnifica página de Linux User Group Krefeld, realizaremos una instalación completa del sistema con Ansible, como digo en el título, vamos a hacerlo a lo bestia (todo en un Playbook, sin roles), pero con un poquito de delicadeza (con variables y plantillas), este artículo nos servirá para ver algunos de los módulos más comunes, y nos hará comernos un poco la cabeza para conservar la idempotencia de Ansible con comandos fuera del propio ansible.

Un poco de plantillas:

Ansible utiliza Jinja2 como motor de plantillas, esto permite utilizar variables con contenido en los playbooks, pero también crear plantillas de ficheros de configuración que podamos arrastrar de una instalación a otra solo cambiando una serie de variables, esto es también lo que se pretende con los roles, pero no he querido meter más información de golpe, primero haremos las cosas bien a lo bestia y luego convertiremos esto en un rol y ya puestos comprobaremos los mecanismos de prueba de los roles.

Las plantillas que vamos a realizar no tienen mucha ciencia, básicamente, crearemos un fichero con las variables que utilizaremos más adelante:

vars_dbmail.yml

dbmail_db_username: "dbmail"
dbmail_db_database: "dbmail"
dbmail_db_password: "dbmail"
tls_cert_file: "/etc/ssl/certs/ssl-cert-snakeoil.pem"
tls_key_file: "/etc/ssl/certs/ssl-cert-snakeoil.key"
tls_ca_file: "/etc/ssl/certs/ssl-cert-snakeoil.pem"

Esto nos permite, simplemente editando este fichero indicar los certificados y los datos de conexión a la base de datos, estos datos serán compartidos por todas las plantillas y comandos que utilicemos lo que limita la posibilidad de error y aumenta la mantenibilidad de todo el sistema. El fichero de variables es otro fichero con sintaxis YAML.

Preparando repositorios:

Vamos paso a paso con las tareas de nuestro playbook, lo primero definir los hosts sobre los que actuaremos, el usuario, cargar los datos de las variables y realizar las tareas que marca la sanidad mental, tales como añadir los repositorios de dbmail, actualizar la cache de apt y los paquetes instalados actualmente:

dbmail.yml(1)

---
- hosts: dbmail
  user: ansrunner
  sudo: yes
  vars_files:
    - vars/vars_dbmail.yml
  tasks:
  - name: añadir repositorios de Dbmail
    apt_repository: repo={{item}} state=present
    with_items:
      - deb http://debian.nfgd.net/debian sid main
      - deb-src http://debian.nfgd.net/debian sid main
  - name: Actualiza cache de apt
    apt: update_cache=yes cache_valid_time=3600
  - name: Actualiza paquetes
    apt: upgrade=yes

En el momento de hacer esto no había paquetes en el repositorio de jessie, por los que he utilizado los de sid sin problemas de versiones. Cabe recordar que las ejecuciones de Ansible son idempotentes, esto es, podemos ejecutarlas todas las veces que queramos que el estado final no depende del inicial, por lo que si ejecutamos esto una segunda vez, volverá a actualizar la cache y a actualizar paquetes, pero no volverá a añadir nuevamente los repositorios porque ya están añadidos, esto nos permite iterar sobre nuestro propio playbook mientras limamos los pequeños problemas que surjan.

Instalando paquetes:

Ya tenemos cargados los repositorios, los paquetes actualizados y las cachés recargadas, es la hora de instalar todos los paquetes necesarios, fragmento que nos servirá para ver la utilidad de las iteraciones en Ansible:

dbmail.yml(2)

  - name: instalar paquetes
    apt: name={{ item }} state=installed allow_unauthenticated=yes
    with_items:
      - "postgresql"
      - "python-psycopg2"
      - "dbmail"
      - "postfix"
      - "postfix-pgsql"
      - "libpam-pgsql"
      - "sasl2-bin"
      - "libsasl2-modules"
      - "amavisd-new"
      - "clamav"
      - "clamav-daemon"
      - "clamav-freshclam"
      - "spamassassin"
      - "pyzor"
      - "razor"
      - "less"
      - "mailutils"
      - "postgrey"
      - "unzip"

En este caso, al no tener la llave GPG del repositorio de dbmail es necesario indicar que se permiten paquetes sin autenticar, este listado de paquetes ya nos servirá para cualquier nueva instalación de dbmail que hagamos sin tener que volver a buscar aquel tutorial del principio.

Usuario, vínculos y directorios:

Ya tenemos los paquetes instalados, ahora nos aseguraremos que grupos y usuarios están creados como queremos y prepararemos los directorios para el jail de postfix. Postfix corre enteramente bajo /var/spool por lo que todos los ficheros a los que tengan que acceder deben estar por debajo de ese árbol.

dbmail.yml(3)

  - name: Verificamos grupos
    group: name={{item}} state=present
    with_items:
      - sasl2
      - dbmail
      - clamav
      - amavis
  - name: Verificamos usuarios
    user: name={{item.name}}  groups={{item.groups}}
    with_items:
      - {name: 'postfix', groups: 'postfix,sasl' }
      - {name: 'dbmail', groups: 'dbmail,sasl,amavis,clamav' }
      - {name: 'clamav', groups: 'dbmail,amavis' }
      - {name: 'amavis', groups: 'dbmail,clamav' }
  - name: Verificamos si existe el directorio /var/run/saslauthd
    stat: path=/var/run/saslauthd
    register: directorio
    
  - name: paramos saslauthd
    service: name=saslauthd state=stopped
    when: directorio.stat.islnk is defined and directorio.stat.islnk == False
    
  - file: path=/var/run/saslauthd state=absent
    when: directorio.stat.isdir is defined and directorio.stat.isdir  
    

  - name: Creando jail para postfix
    file: path={{item.path}} state={{item.state }} mode={{item.mode}}
    with_items:
      - {path: '/var/spool/postfix/var/run', state: 'directory', mode: 'ugo+rwx'}
      - {path: '/var/spool/postfix/var/run/saslauthd', state: 'directory', mode: 'ugo+rwx'

  - name: añade sasl dir a postfix jail
    file:
      src: /var/spool/postfix/var/run/saslauthd
      dest: /var/run/saslauthd
      owner: root
      group: root
      state: link
    when: directorio.stat.islnk is defined and directorio.stat.islnk == False
    
  - name: arrancamos saslauthd
    service: name=saslauthd state=started
    when: directorio.stat.islnk is defined and directorio.stat.islnk == False
      

Aquí introducimos por primera vez los registros de salida y las sentencias condicionales, la idea es que si tras la instalación saslauthd está en ejecución no podremos crear el jail, por lo que tenemos que verificar que existe el directorio original de /var/run/saslauthd (con stat y guardando el resultado en la variable directorio) y que sigue siendo un directorio. Si esto es así es que no hemos hecho el jail (recordemos la idempotencia...) así que en ese caso (when: directorio.stat.islnk is defined and directorio.stat.islnk == False) pararemos el servicio, eliminaremos el directorio antiguo, crearemos el nuevo y los vincularemos.

Operaciones con bases de datos:

El siguiente paso es crear usuario y base de datos, y comprobar que el usuario tiene acceso y control de la base de datos.

dbmail.yml(4)

- name: verifica que la bdd esta creada
    become: true
    become_user: postgres
    postgresql_db: state=present db={{ dbmail_db_database }} login_user=postgres
        
  - name: verifica que el usuario esta creado
    become: true
    become_user: postgres
    postgresql_user: name={{ dbmail_db_username }} password={{ dbmail_db_password }} state=present priv=CONNECT db={{ dbmail_db_database }} login_user=postgres
        
  - name: verifica que el usuario tiene los privilegios correctos
    become: true
    become_user: postgres
    postgresql_user: name={{ dbmail_db_username }} role_attr_flags=LOGIN login_user=postgres
        
  - name: verifica que el usuario puede modificar la bdd
    become: true
    become_user: postgres
    postgresql_privs: privs=ALL type=schema objs=public role={{ dbmail_db_username }} db={{ dbmail_db_database }} login_user=postgres

Configuraciones:

Hemos preparado los ficheros de configuración como plantillas y a todos les añadimos el encabezado:

#
# {{ ansible_managed }}
# Please do not edit manually
# Last modified: {{ ansible_date_time.date }}
#

Que nos servirá para saber qué fichero viene de ansible y en qué fecha se modificó, cuando montemos git para control de versiones también podremos poner datos del control de versiones. Esta cabecera al pasar por las manos del motor de plantillas quedará así en el servidor de destino:

#
# Ansible managed
# Please do not edit manually
# Last modified: 2017-01-31
#

En el fichero adjunto podréis encontrar todas las plantillas que se despliegan a continuación:

dbmail.yml(5)

  - name: prepara configuraciones
    template: src={{ item.source }} dest={{ item.dest }}
    with_items:
      - {source: 'templates/postfix_main_cf.j2', dest: '/etc/postfix/main.cf'}
      - {source: 'templates/postfix_master_cf.j2', dest: '/etc/postfix/master.cf'}
      - {source: 'templates/sql-virtual_mailbox_domains.cf.j2', dest: '/etc/postfix/sql-virtual_mailbox_domains.cf'}
      - {source: 'templates/sql-virtual_mailbox_maps.cf.j2', dest: '/etc/postfix/sql-virtual_mailbox_maps.cf'}      
      - {source: 'templates/default_dbmail.j2', dest: '/etc/default/dbmail'}
      - {source: 'templates/dbmail_conf.j2', dest: '/etc/dbmail/dbmail.conf'}
      - {source: 'templates/default_saslauthd.j2', dest: '/etc/default/saslauthd'}
      - {source: 'templates/postfix_sasl_smtpd_conf.j2', dest: '/etc/postfix/sasl/smtpd.conf'}
      - {source: 'templates/pam_pgsql_conf.j2', dest: '/etc/pam_pgsql.conf'}
      - {source: 'templates/pam_d_smtp.j2', dest: '/etc/pam.d/smtp'}
      - {source: 'templates/updatechannels_txt.j2', dest: '/etc/spamassassin/updatechannels.txt'}
      - {source: 'templates/default_spamassassin.j2', dest: '/etc/default/spamassassin'}
      - {source: 'templates/spamassassin_local_cf.j2', dest: '/etc/spamassassin/local.cf'}
      - {source: 'templates/dotpgpass.j2', dest: '~/.pgpass'}
      
  - name: Cambiando permisos .pgpass
    file: path=~/.pgpass state=file mode=0600

La última parte nos permite crear un fichero .pgpass con los datos de conexión del usuario de dbmail a la base de datos, lo que nos permitirá más adelante llamar a comandos como psql sin tener que incluir la contraseña. El fichero se recomienda que tenga permisos solo de lectura y escritura para el usuario(0x600 en octal) para evitar que alguien pueda leer la contraseña (los ficheros de configuración con demasiado permiso han provocado grandes catástrofes)

Comprobaciones para la base de datos:

No existe módulo de psql para Ansible, así que hay que controlar un poco la ejecución, si simplemente ponemos los comandos en una shell perderemos la idempotencia en futuras ejecuciones, para ello hacemos lo siguiente:

dbmail.yml(6)

  - name: prepara configuraciones
    template: src={{ item.source }} dest={{ item.dest }}

  - name: comprueba que las tablas están creadas.
    command: psql -U {{ dbmail_db_username }} -h localhost -w -q -c "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'dbmail_users');"
    register: respsql

  - name: Verificamos si está copiado el fichero ~/create_tables.pgsql
    stat: path=~/create_tables.pgsql
    register: tablas

Es decir, hacemos un stat al fichero de creación de las tablas que proporciona dbmail, esto es necesario porque el fichero es un .gz y unarchive no maneja .gz de forma nativa así que lo haremos por línea de comandos, el resultado de stat lo guardamos en "tablas". Por otro lado, la consulta que ejecutamos comprueba si existe la tabla "dbmail_users", el script de creación de la base de datos de PostgreSQL es una única transacción, por lo que si una tabla está creada asumimos que todo el esquema está creado.

Creación del esquema de datos y ajustes de configuración:

dbmail.yml(7)

  - name: copia el SQL de las tablas
    copy: src='/usr/share/doc/dbmail/examples/create_tables.pgsql.gz' dest='~/' remote_src=true
    when: tablas.stat.exists is defined and tablas.stat.exists == False
    
  - name: desempaqueta las tablas
    command: gunzip '~/create_tables.pgsql.gz'
    when: tablas.stat.exists is defined and tablas.stat.exists == False
    
  - name: importa fichero de tablas
    command: psql -U {{ dbmail_db_username }} -h localhost -w -q -f ~/create_tables.pgsql
    when: respsql.stdout_lines[2] is defined and respsql.stdout_lines[2] | search('f')      
      
  - name: Modificamos configuración de pid de postgres
    lineinfile:
      dest: /etc/postgresql/9.4/main/postgresql.conf
      state: present
      regexp: '^external_pid_file'
      line: "external_pid_file = '/var/spool/postfix/var/run/postgresql/9.4-main.pid'"

  - name: Modificamos configuración de sockets de postgres
    lineinfile:
      dest: /etc/postgresql/9.4/main/postgresql.conf
      state: present
      regexp: '^unix_socket_directories'
      line: "unix_socket_directories = '/tmp,/var/spool/postfix/var/run/postgresql'"

Para controlar que los comandos no estén repetidos utilzamos la sentencia condicional when, de manera que si existe el fichero de las tablas no hace falta que lo copiemos ni lo descomprimamos (when: tablas.stat.exists is defined and tablas.stat.exists == False) y de tal manera que solo cargamos el esquema si el resultado del comando ejecutado en psql es "false", es decir, no existe la tabla.

Es importante que mantengamos el "/tmp" en los directorios de socket de postgres o de lo contrario el script de systemd no lo encontrará y fallará.

El toque final..

Ya solo nos queda reiniciar los servicios y tenemos cocinado el pollo:

dbmail.yml(8)

  - name: re-arrancamos servicios
    service: name={{ item }} state=restarted
    with_items:
      - "saslauthd"
      - "clamav-daemon"
      - "clamav-freshclam"
      - "amavis"
      - "spamassassin"
      - "postgrey"
      - "dbmail"

Y ahora llega el momento en el que el avezado lector que haya llegado hasta aquí se pregunta... ¿y todo este trabajo merece la pena? Pues hombre, si vas a montar un servidor de DBmail en toda tu vida, pues, igual no. Ahora si montas un servidor de dbmail y un año después tienes que migrarlo y no te acuerdas de lo que hiciste pues ya suma. Si montas un servidor a la semana sin ninguna duda, pero sobre todo, si tienes montados 50 servidores de DBmail (o 2, que con 2 ya vale) y tienes que aplicar un cambio en la configuración, te acordarás de todo esto y de las plantillas.

Además de eso, te permite trastear tranquilamente en una máquina virtual (le habré pegado 100 vueltas al script) sin romper nada y una vez que acabes solo tienes que coger un servidor nuevo, instalación básica de Debian, credenciales y:

vagrant@mgmt:~/ficheros$ time ansible-playbook crea_usuario.yml -k -K

.....

PLAY RECAP *********************************************************************
servidor de correo nuevo        : ok=5    changed=4    unreachable=0    failed=0

real    0m17.233s

user    0m2.892s
sys     0m4.256s

vagrant@mgmt:~/ficheros$ time ansible-playbook dbmail.yml

...

PLAY RECAP *********************************************************************
servidor de correo nuevo        : ok=27   changed=22   unreachable=0    failed=0

real    5m31.102s
user    0m21.776s
sys     1m22.996s

En menos de 6 minutos un servidor de correo montado y funcionando, imagina que tienes un servidor de correo con dbmail y un servidor de base de datos, el servidor se ve comprometido o simplemente se estropea, puedes levantar el servicio en 6 minutos en otro servidor, sin errores de configuración... ¡Ahí es nada!

Estos son los ficheros (a falta de las variables)

templates.zip

dbmail.yml