Uso de "nullmailer" en zonas nativas SmartOS

Las zonas nativas SmartOS incorporan un servidor de correo Postfix que hay que configurar correctamente para procesar los mensajes que puedan generarse de forma local como resultado de ejecuciones fallidas del demonio CRON o servicios similares. Enfrentado a este problema, lo que necesito exclusivamente es un sistema de "mail forwarding" que envíe todo el correo generado de forma local a mi servidor de correo normal, que reside en otra máquina diferente. No quiero inteligencia de ningún tipo, tan solo un simple "mail forwarding" ciego.

Se puede configurar Postfix para que haga "mail forwarding", de hecho es una configuración frecuente, pero me parece matar moscas a cañonazos. Preguntando sobre el particular en el canal IRC de SmartOS, muy amigable, se me sugirió un paquete disponible directamente en Pkgsrc: nullmailer.

Nullmailer hace exacta y exclusivamente lo que necesito: enviar a un servidor de correo remoto todo el correo que se genere de forma local.

No obstante, hay un problema: Nullmailer contiene un bug que hace que los mensajes generados por CRON, precisamente mi caso de uso, se reciban sin cabeceras; las cabeceras acaban como cuerpo del mensaje. Es decir, el mensaje que te llega es algo de este tipo:

From root Mon Nov 14 23:42 UTC 2016
To: root
Subject: Cron <root@5ca32ab7-57b7-4af4-8740-69fcd29cef86> echo hi
Auto-Submitted: auto-generated
X-Mailer: cron (SunOS 5.11)
X-Cron-User: root
X-Cron-Host: 5ca32ab7-57b7-4af4-8740-69fcd29cef86
X-Cron-Job-Name: echo hi
X-Cron-Job-Type: cron
MIME-Version: 1.0
Content-Type: text/plain
Content-Length: 3

hi

El problema es que a nullmailer no le gustan los mensajes cuya primera línea empieza por "From " o ">From ", aunque son legales.

La solución evidente sería parchear nullmailer, pero no quiero modificar paquetes binarios de pkgsrc. En vez de ello lo que he hecho es un wrapper en Python que filtra el mensaje entrante y simplemente se salta la primera línea si empieza por "From " o ">From ".

La filosofía de zonas nativas SmartOS es que no se parchean o reconfiguran una vez que están funcionando en producción. Cada vez que sale una versión nueva de pkgsrc lo que se hace es destruir la zona y reprovisionarla desde cero. Como eso ocurre cuatro veces al año (cada tres meses), estoy usando Ansible para automatizar la tarea. De esta forma evito errores y el proceso de reprovisionar docenas de zonas con cientos de servicios me lleva, exactamente, seis minutos.

En concreto he creado un rol "nullmailer" en Ansible. Sus detalles son los siguientes:

  • Fichero roles/nullmailer/files/adminaddr:

    root@jcea.es
    

    Esto indica a nullmailer que todo el correo que se iba a entregar en local debe entregarse, en cambio, en la dirección root@jcea.es.

  • Fichero roles/nullmailer/files/remotes:

    10.200.0.7 smtp
    

    Aquí indicamos a nullmailer que debe enviar todo el correo al servidor SMTP en esa dirección IP. Es mi servidor de correo principal. Lo quiero todo ahí.

  • Fichero roles/nullmailer/files/mailer.conf:

    #
    # This file configures mailwrapper(1M).
    # For details see mailer.conf(4).
    # The following configuration is correct for nullmailer(7).
    #
    
    sendmail        /opt/local/libexec/nullmailer/sendmail
    newaliases      /opt/local/libexec/nullmailer/sendmail
    mailq           /opt/local/libexec/nullmailer/mailq
    

    Este fichero se copiará en el sistema operativo y configurará cómo gestiona el correo electrónico generado de forma local.

  • Fichero roles/nullmailer/files/nullmailer-inject:

    #!/opt/local/bin/python
    
    import sys
    import subprocess
    
    args = ['/opt/local/libexec/nullmailer/nullmailer-inject2'] + sys.argv[1:]
    proc = subprocess.Popen(args, bufsize=-1,
                            stdin=subprocess.PIPE,
                            stdout=subprocess.PIPE,
                            stderr=subprocess.PIPE,
                            close_fds=True)
    
    first = sys.stdin.readline()
    if not (first.startswith('From ') or first.startswith('>From ')):
        proc.stdin.write(first)
    
    for line in sys.stdin:
        proc.stdin.write(line)
    
    stdoutdata, stderrdata = proc.communicate()
    if stdoutdata:
        sys.stdout.write(stdoutdata)
    if stderrdata:
        sys.stderr.write(stderrdata)
    
    sys.exit(proc.returncode)
    

    Esto es mi wrapper Python. Es muy simple. Se instalará entre dos componentes de nullmailer y realizará un procesado trivial del email en curso. Sencillamente elimina la primera línea del mensaje si empieza por "From " o ">From ". El programa se preocupa mucho de ser lo más transparente posible, especialmente si el proceso de inyección de correo falla.

  • Fichero roles/nullmailer/tasks/main.yml:

    ansible-nullmailer.yml (Código fuente)

    # Basado en
    # - Conversación IRC del 12 de noviembre de 2016
    # - https://github.com/skylime/mi-core-base/blob/master/copy/etc/mailer.conf
    # - https://github.com/skylime/mi-core-base/blob/master/copy/opt/core/var/mdata-setup/includes/52-nullmailer.sh
    # - https://github.com/skylime/mi-core-base/blob/master/copy/var/zoneinit/includes/17-nullmailer.sh
    
    - name: Desinstala Postfix
      pkgin: name=postfix state=absent
    
    - name: Instalar nullmailer
      pkgin: name=nullmailer state=present
    
    - name: Activamos el spool persistente para nullmailer
      zfs: name={{ ansible_mounts.0.device }}/data/nullmailer-spool
           mountpoint=/home/nullmailer-spool
           state=present
    
    # TODO ESTO ES NECESARIO PORQUE 'QUEUE' Y 'TMP' TIENEN
    # QUE ESTAR EN EL MISMO SISTEMA DE FICHEROS.
    - name: Creamos subdirectorios del spool
      file: name=/home/nullmailer-spool/{{ item }}
            state=directory
            mode=0700 owner=nullmail group=nullmail
      with_items:
        - .
        - queue
        - tmp
    
    # https://stackoverflow.com/questions/27006925/how-to-replace-a-directory-with-a-symlink-using-ansible
    - name: Comprobamos el estado de los enlaces simbólicos
      stat: path=/var/spool/nullmailer/{{ item }}
      register: directorios
      with_items:
        - queue
        - tmp
    
    # Lo de "size" no es portable. Funciona en SmartOS. Al menos, en otros
    # sistemas fallará de forma segura, porque no será "2".
    - name: Eliminamos las carpetas del spool para crear los enlaces simbólicos luego, si están vacías
      file: path=/var/spool/nullmailer/{{ item.item }} state=absent
      when: item.stat.isdir is defined and item.stat.isdir and item.stat.size is defined and item.stat.size==2
      with_items: "{{ directorios.results }}"
      loop_control:
        label: "{{ item.item }}"
    
    - name: Creamos enlaces simbólicos al spool persistente
      file: path=/var/spool/nullmailer/{{ item }}
            src=/home/nullmailer-spool/{{ item }}
            state=link
            mode=0700 owner=nullmail group=nullmail
      with_items:
        - queue
        - tmp
    
    - name: Copiamos fichero de configuración de mailer.conf
      copy: src=mailer.conf dest=/etc/mailer.conf
    
    - name: Copiamos el fichero de configuración "remotes" de nullmailer
      copy: src=remotes dest=/opt/local/etc/nullmailer/remotes
    
    - name: Copiamos el fichero de configuración "adminaddr" de nullmailer
      copy: src=adminaddr dest=/opt/local/etc/nullmailer/adminaddr
    
    - name: Identificamos el nombre del host para nullmailer
      shell: echo `/usr/bin/hostname`.jcea.es > /opt/local/etc/nullmailer/me
      args:
        creates: /opt/local/etc/nullmailer/me
    
    - name: Preparamos el "wrapping" para nullmailer
      command: mv /opt/local/libexec/nullmailer/nullmailer-inject
                  /opt/local/libexec/nullmailer/nullmailer-inject2
      args:
        creates: /opt/local/libexec/nullmailer/nullmailer-inject2
    
    - name: Subimos el wrapper
      copy: src=nullmailer-inject
            dest=/opt/local/libexec/nullmailer/nullmailer-inject
            mode=0755
    
    - name: Activa nullmailer
      service: name=nullmailer enabled=yes state=started
    

    Nullmailer es incompatible con Postfix, así que desinstalamos uno e instalamos el otro (líneas 7-16).

    Un detalle importante que complica el rol ansible es que quiero mantener el contenido del spool de correo aunque destruya y reprovisione la zona SmartOS. Es decir, me esfuerzo al máximo en no perder el correo en tránsito. Por eso creamos un spool en un lugar que sobrevive a los reprovisionamientos (líneas 18-53). Esta parte es fea y poco portable, pero no he encontrado cómo hacerlo mejor con Ansible. Acepto sugerencias :).

    En las líneas 55-67 preparamos la configuración de nullmailer (especial atención a las líneas 64-67).

    La inyección de mi wrapper Python se realiza en las líneas 69-78. Finalmente activamos nullmailer en las líneas 80-81.

  • Configuramos nuestra instalación principal de Postfix para que acepte correo electrónico de las zonas SmartOS sin rechistar. En main.cf ponemos:

    mynetworks = 176.9.11.11/32, 127.0.0.0/8, 10.200.0.0/24
    

Problemas

  • Aunque involucrar mi wrapper en cada email es lento y consumo recursos, lo cierto es que no me molesta consumir un segundo extra en la generación de media docena de emails al día. No es un problema en mi entorno.

  • El uso del wrapper Python para rodear el bug de nullmailer introduce el problema de que si actualizamos la instalación de nullmailer por cualquier motivo, el wrapper se sobreescribirá. Por tanto, nos empezarán a llegar mensajes de correo electrónico generados por CRON con las cabeceras en el cuerpo del email. Afortunadamente, cuando veamos el primer mensaje de este tipo, sabremos dónde tocar. Lamentablemente volver a ejecutar este rol Ansible no nos ayudará porque detecta que ya existe el fichero /opt/local/libexec/nullmailer/nullmailer-inject2 y supondrá que ya hemos instalado el wrapper.

    En la práctica esto no es problema porque, como ya he dicho antes, el procedimiento habitual con zonas SmartOS es no actualizarlas in situ, sino destruirlas y reprovisionarlas desde cero. Cuando se haga eso, el sistema operativo estará limpio y este rol Ansible funcionará bien.

  • Cuando pkgsrc distribuya una versión de nullmailer sin el bug, tendré que reescribir este rol Ansible.

Historia

Actualización 20161213: Sebastian Wiedenroth parchea la versión pkgsrc de nullmailer para incorporar su parche.

Actualización 20170307: El autor original de nullmailer soluciona el bug.

Actualización 20171028: pkgsrc actualiza nullmailer a la versión 2.1. Es previsible que esta actualización rompa mi rol ansible. En cualquier caso mi wrapper es ya innecesario.