Poesía Binaria

Cómo crear milters en Python y configurar Postfix para filtrar correo


Una de las herramientas fundamentales a la hora de montar nuestro propio servidor de correo es la implementación de filtros (Mail Filters) para seleccionar de forma eficiente el correo que vamos a procesar. Entre otras cosas, podremos:

Por otro lado, cuando montamos un nuevo sistema debemos pensar en la futura escalabilidad del mismo. Es decir, vamos a pensar en cuando nuestro sistema sea grande. Los servicios tanto de entrega, recepción de correo, incluso filtros podrán estar en máquinas separadas. Así que la forma de atacar a estos sistemas será haciendo conexiones de red. Puede que para un sistema muy pequeño perdamos algo de rendimiento, ya que el establecimiento de la conexión y la negociación de la misma puede tardar un tiempo que no tardaría la ejecución de un programa local, pero que hacen posible la separación de los servicios, incluso podríamos colocar los filtros detrás de un balanceador de carga y destinar varias máquinas a éstos. De todas formas, para este ejemplo, vamos a utilizar sockets unix por lo que, en local, será muy rápido y no será complicado hacer que en lugar de un socket Unix estemos utilizando un host/puerto.

Configuración de Postfix

Postfix es capaz de ejecutar muchos milters cada vez que viene un mensaje. Estos milters, con respecto a cuándo se ejecutan antes de encolar los mensajes y pueden ser:

Solo tenemos que ir al fichero /etc/postfix/main.cf y, por ejemplo al final, añadir:

smtpd_milters = milter1, milter2, {milter3, …},…
milter_default_action = accept

De esta forma, por defecto, si algún milter no se ejecuta, por defecto aceptamos el mensaje. Por ejemplo, si estamos utilizando un milter de monitorización, si el milter falla, que por lo menos se entregue el mensaje. Si es un milter de filtro de correo, debemos ver qué es más importante, que cuando el milter falle rechace todo (reject) o si deberíamos entregarlo. Otras acciones que pueden suceder cuando el milter falle son tempfail (fallo temporal) o quarantine (cuarentena).

Como hemos visto, podemos separar una serie de filtros por comas, incluso, a veces, ponemos configuración del milter entre llaves. Eso sí, si solo tenemos uno, no hacen falta comas, y si la configuración es sencilla, llaves tampoco. Vamos a ver un poco el por qué de todo esto.

En principio, con respecto a cómo se ejecutan, podemos tener otros dos tipos de milters:

También hemos visto que los milters podemos ponerlos entre llaves, esto es cuando su configuración es más compleja que solo decir dónde tiene que conectar, por ejemplo podemos controlar:

Así, un ejemplo de configuración de milter puede ser:

smtpd_milters = unix:/var/run/milters/monitor, {inet:blacklist.mydomain.com:6234, connection_timeout=5x, default_action=tempfail, command_timeout=1s}
milter_default_action = accept

Milters en Python

Aunque podemos crear nuestros milters en C o C++, es más, podemos encontrar muchos ejemplos que utilizan libmilter y funcionan muy bien. Hoy le toca el turno a Python, de hecho la biblioteca de Python para trabajar con milters se apoya en la biblioteca de C. Aunque no tendrá tanto rendimiento como uno programado en C, en el hipotético caso en el que el filtro lo hagamos optimizado en cada lenguaje, Python nos hace mucho más fácil el mantenimiento y el desarrollo, pudiendo crecer muy rápidamente. Lo primero será preparar las dependencias. Podemos instalarlas con pip:

sudo pip install pymilter

Aunque, dependiendo de nuestro filtro, podremos tener muchas más dependencias. Ya nuestra imaginación no tiene límites.

Creando el milter en Python

Vamos a crear un milter de ejemplo en Python. En este ejemplo, solo vamos a permitir mensajes que se envíen hacia las direcciones de correo que figurarán en un archivo llamado whitelist. Por lo que nuestro servidor no podrá mandar correo a cualquiera. El contenido de whitelist puede ser el siguiente:

mi@correo.com
yo@dominio.com
otromail@otrodominio.com

El milter está basado en este. Ahí va el código:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# -*- coding: utf-8 -*-
# Milter para filtrar los mails que vienen de direcciones entrantes

import Milter
import StringIO
import time
import email
import sys
from socket import AF_INET, AF_INET6
from Milter.utils import parse_addr

if True:
  from multiprocessing import Process as Thread, Queue
else:
  from threading import Thread
  from Queue import Queue

logq = Queue(maxsize=4)

class myMilter(Milter.Base):

  def __init__(self):  # Se crea una instancia con cada conexión entrante
    self.id = Milter.uniqueID()

  @Milter.noreply
  def connect(self, IPname, family, hostaddr):
    self.IP = hostaddr[0]
    self.port = hostaddr[1]
    if family == AF_INET6:
      self.flow = hostaddr[2]
      self.scope = hostaddr[3]
    else:
      self.flow = None
      self.scope = None
    self.IPname = IPname  
    self.H = None
    self.fp = None
    self.receiver = self.getsymval('j')
    self.log("connect from %s at %s" % (IPname, hostaddr) )
   
    return Milter.CONTINUE


  def hello(self, heloname):
    # Cuando entra un nuevo mail vemos esto.
    self.log("Recibo HELO")
    self.H = heloname
    self.log("HELO %s" % heloname)
     
    return Milter.CONTINUE

  def envfrom(self, mailfrom, *str):
    # Cuando se indica un remitente entra por aquí. Podemos elegir si continuar o no
    self.F = mailfrom
    self.R = []
    self.fromparms = Milter.dictfromlist(str)
    self.user = self.getsymval('{auth_authen}')
    self.log("mail from:", mailfrom, *str)

    self.fp = StringIO.StringIO()
    self.canon_from = '@'.join(parse_addr(mailfrom))
    self.fp.write('From %s %s\n' % (self.canon_from,time.ctime()))
    return Milter.CONTINUE


  def envrcpt(self, to, *str):
    # Cuando recibimos un destinatario, entra por aquí
    filename = 'whitelist'
    # Obtenemos el listado de correos aceptados
    addresses = tuple(el.strip() for el in open(filename, 'r') if el.strip())

    tomail = '@'.join(parse_addr(to))

    if not any(tomail.lower() in l for l in addresses):
       return Milter.REJECT

    rcptinfo = to,Milter.dictfromlist(str)
    self.R.append(rcptinfo)
   
    return Milter.CONTINUE


  @Milter.noreply
  def header(self, name, hval):
    # Cuando recibimos un encabezado entramos por aquí
    self.fp.write("%s: %s\n" % (name,hval))
    return Milter.CONTINUE

  @Milter.noreply
  def eoh(self):
    # Cuando terminamos los encabezados entramos a esta función
    self.fp.write("\n")                        
    return Milter.CONTINUE

  @Milter.noreply
  def body(self, chunk):
    # Cuando se recibe cada uno de los trozos del cuerpo del mensaje entramos a este punto
    self.fp.write(chunk)
    return Milter.CONTINUE

  def eom(self):
    # Cuando terminamos de recibir el mensaje entramos a esta función
    self.fp.seek(0)
    msg = email.message_from_file(self.fp)
    return Milter.ACCEPT

  def close(self):
    # Cierre de conexión y limpieza de recursos. Si se llama a abort() se llamará luego a close()
    # automáticamente
    return Milter.CONTINUE

  def abort(self):
    # El cliente se ha desconectado de forma prematura. ¿Un fallo en postfix o al enviar el mensaje?
    return Milter.CONTINUE

  def log(self,*msg):
    # Logea algo
    logq.put((msg,self.id,time.time()))

def background():
  while True:
    t = logq.get()
    if not t: break
    msg,id,ts = t
    print "%s [%d]" % (time.strftime('%Y%b%d %H:%M:%S',time.localtime(ts)),id),

    for i in msg: print i,
    print

## ===
   
def main():
  bt = Thread(target=background)
  bt.start()
  socketname = "/var/run/milters/whitelistmilter"
  timeout = 600

  Milter.factory = myMilter
  flags = Milter.CHGBODY + Milter.CHGHDRS + Milter.ADDHDRS
  flags += Milter.ADDRCPT
  flags += Milter.DELRCPT
  # Le decimos al Milter los puntos que "interceptaremos"
  Milter.set_flags(flags)      
  print "%s milter startup" % time.strftime('%Y%b%d %H:%M:%S')
  sys.stdout.flush()
  Milter.runmilter("pythonfilter",socketname,timeout)
  logq.put(None)
  bt.join()
  print "%s bms milter shutdown" % time.strftime('%Y%b%d %H:%M:%S')

if __name__ == "__main__":
  main()

Probando nuestro milter

Para probar esto, desde un terminal, podemos ejecutar nuestro script python de nuestro milter, reiniciar Postfix e intentar enviar un correo a través del servidor SMTP elegido. Si queremos hacerlo todo desde terminal, podemos probar utilizar este script para enviar correos desde terminal.

Aunque todo esto podemos hacerlo, si la configuración nos lo permite a pelo. Y puede que en el futuro haga un post dedicaco a esto. Pero por ahora nos apañaremos con este apartado. Nuestro servidor de correo requeire autentificación, de hecho para las pruebas he utilizado un Postfix configurado como relay, con toda la configuración del anterior post. incluyendo el nombre de usuario y contraseña para poder acceder al servidor de correo. Para ello, y como vamos a utilizar telnet, vamos a prepararnos en un terminal la siguiente información:

echo -n ‘nombre_de_usuario’ | openssl base64
bm9tYnJlX2RlX3VzdWFyaW8=
echo -n ‘contraseña’ | openssl base64
Y29udHJhc2XDsWE=

Como estamos utilizando en el servidor el modo AUTH LOGIN, tendremos que escribir el usuario y la contraseña codificados en base64. Los textos codificados, podemos dejarlos visibles para copiarlos y pegarlos cuando convenga. Ahora, accederemos al servidor de correo (el puerto será el 25, y si lo tenemos instalado en un VPS, tendremos que asegurarnos de que nuestro proveedor tiene abierto el puerto 25. A veces necesitamos enviar un mensaje al proveedor para que lo activen). ¡Fijaos, la IP es digna de CSI!

telnet mi.servidor.de.correo.com 25
Trying 258.259.260.261…
Connected to 258.259.260.261.
Escape character is ‘^]’.
220 mi.servidor.de.correo.com ESMTP Postfix (Debian/GNU)
EHLO dominio
250-mi.servidor.de.correo.com
250-PIPELINING
250-SIZE 10240000
250-VRFY
250-ETRN
250-AUTH LOGIN PLAIN CRAM-MD5 DIGEST-MD5 NTLM
250-ENHANCEDSTATUSCODES
250-8BITMIME
250-DSN
250 SMTPUTF8
AUTH LOGIN
334 VXNlcm5hbWU6
bm9tYnJlX2RlX3VzdWFyaW8=
334 UGFzc3dvcmQ6
Y29udHJhc2XDsWE=
235 2.7.0 Authentication successful
MAIL FROM: probando@dominio.com
250 2.1.0 Ok
RCPT TO: direccion@noestaenlalista.com
550 5.7.1 Command rejected
RCPT TO: direccion@SIestaenlalista.com
250 2.1.5 Ok
DATA
354 End data with .
Escribo un correo solo con cuerpo
Sin asunto
.
250 2.0.0 Ok: queued as 6110CA0A43

Como vemos, cuando introducimos una dirección de correo que no está en la lista, nos aparece el comando como rechazado (Command rejected), mientras que cuando la dirección está en la lista, nos devuelve Ok. Si queremos, podemos mirar el log en la ventana donde estamos ejecutando el script para ver el resultado y observar que efectivamente están llegando los comandos a nuestro milter.

Creación del servicio

Como queremos que nuestro milter siempre esté en ejecución, debemos crear un servicio. Por ejemplo, para systemd podemos crear el siguiente archivo: whitelistmilter.service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[Unit]
Description=Milter startup
After=networking.target

[Service]
WorkingDirectory=/home/user/milters/
Type=simple
ExecStart=/usr/bin/python /home/user/milters/whitelistmilter.py
KillSignal=SIGTERM
User=postfix
Group=postfix

[Install]
WantedBy=multi-user.target

Luego podemos crear un enlace a /etc/systemd/system y arrancar el servicio:

sudo ln -s $(pwd)/whitelistmilter.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable whitelistmilter
sudo systemctl start whitelistmilter

En este punto ya podemos reiniciar Postfix para empezar a utilizar el filtro:

sudo systemctl restart postfix.service

Problemas que pueden surgir

Postfix chroot y acceso a sockets Unix

Uno de los problemas más comunes que podemos tener es que Postfix se ejecuta en un chroot o jaula. Por lo que, cuando especificamos la ruta de un socket Unix, la ruta esté correcta y todo deba funcionar perfectamente. En los ficheros de log veremos que la ruta no se encuentra. Esto se debe a que el unix socket no está dentro del chroot de postfix y efectivamete no se verá.

Una posible solución a esto es meter nuestro sockets unix de los milters en el directorio /var/run/milters y luego hacer:

sudo mkdir /var/spool/postfix/var/run
sudo mount --bind /var/run/milters /var/spool/postfix/var/run/milters

Con esto, creamos un directorio var/run dentro de la ruta de la jaula de postfix y luego montamos el directorio local /var/run/milters dentro de la jaula. De esta forma, postfix ya tendrá acceso a los milters bajo su ruta absoluta /var/run/milters (recordemos que para Postfix, el directorio raíz / es el directorio /var/spool/postfix para el resto de los mortales.

Foto principal: unsplash-logoandrew welch

También podría interesarte....