Esta pagina se ve mejor con JavaScript habilitado

Monitorización de SendMail mediante exporter propio de Prometheus

 ·  🎃 kr0m

En este manual veremos como monitorizar los emails enviados/recibidos y el estado de las colas en un servidor de correo SendMail mediante Prometheus, si no hemos realizado la instalación base de Prometheus primer seguiremos la guía básica de instalación de Prometheus y Grafana.

El primer paso será habilitar las stats de SendMail:

touch /var/log/sendmail.stats
cd /etc/mail

Generamos el fichero de configuración:

make

Lo editamos:

vi HOSTNAME.mc

define(`STATUS_FILE',`/var/log/sendmail.stats')dnl

Compilamos la configuración y la aplicamos:

make
cp HOSTNAME.cf sendmail.cf

Reiniciamos el servicio:

service sendmail restart

Ahora mediante el siguiente comando podemos obtener las estadísticas:

mailstats

Statistics from Tue Jan  3 18:22:55 2023
 M   msgsfr  bytes_from   msgsto    bytes_to  msgsrej msgsdis msgsqur  Mailer
=====================================================================
 T        0          0K        0          0K        0       0       0
 C        0                    0                    0

Si enviamos un email desde el servidor SendMail veremos la siguiente salida:

Statistics from Tue Jan  3 18:23:34 2023
 M   msgsfr  bytes_from   msgsto    bytes_to  msgsrej msgsdis msgsqur  Mailer
 3        1          1K        0          0K        0       0       0  local
 5        0          0K        1          2K        0       0       0  esmtp
=====================================================================
 T        1          1K        1          2K        0       0       0
 C        1                    1                    0

Observamos que el mail se genera desde local(msgsfr) a esmtp(msgsto)

Si recibimos un email en el servidor SendMail veremos la siguiente salida:

Statistics from Tue Jan  3 18:23:34 2023
 M   msgsfr  bytes_from   msgsto    bytes_to  msgsrej msgsdis msgsqur  Mailer
 3        1          1K        1          5K        0       0       0  local
 5        1          4K        1          2K        0       0       0  esmtp
=====================================================================
 T        2          5K        2          7K        0       0       0
 C        2                    2                    0

Observamos que el mail se genera desde esmtp(msgsfr) a local(msgsto)

Las dos útlimas filas son los Totales: T que a mi parecer teniendo los parciales no tiene sentido mostrarlos y los mensajes enviados por conexión TCP que tampoco le veo sentido.

  • connectionMessagesFrom: Number of messages sent over TCP connections.
  • connectionMessagesTo: Number of messages received over TCP connections.
  • connectionMessagesRejected: Number of messages that arrived over TCP connections and were rejected.

Por lo tanto para la monitorización de emails enviados y tráfico entrante/saliente debemos obtener los siguientes campos.

  • Emisión:
Aumento esmtp(msgsto)
Aumento esmtp(bytes_to)
  • Recepción:
Aumento local(msgsto)
Aumento local(bytes_to)

Otra métrica interesante es el tamaño de la cola del servidor SMTP, observar un email encolado en un sistema idle es complicado, así que definiremos una regla a nivel de firewall donde permitiremos la entrada del email pero no las salida de este modo el email quedará encolado:

ipfw add 00010 allow all from any to any via lo0
ipfw add 00011 deny tcp from any to any 25

Consultamos la cola:

mailq

		/var/spool/mqueue (1 request)
-----Q-ID----- --Size-- -----Q-Time----- ------------Sender/Recipient-----------
19DAkLMh052596      575 Tue Jan 03 18:46 <kr0m@alfaexploit.com>
                 (Deferred: Permission denied)
					 <jjivarspoquet@gmail.com>
		Total requests: 1

Borramos la regla de firewall y vemos que ya no hay mails encolados:

ipfw delete 00010
ipfw delete 00011

mailq
/var/spool/mqueue is empty
		Total requests: 0

Sendmail procesa los mails en colas distintas dependiendo del origen de estos, las colas por defecto son:

  • Cola mqueue: El mail fué introducido en el sistema por un proceso corriendo como root, suelen ser los emails de entrada, estos emails podemos encontrarlos en /var/spool/mqueue/
  • Cola clientmqueue: El mail fué introducido en el sistema por cualquier otro usuario distinto a root, suelen ser mails enviados por usuarios regulares del sistemas, estos emails podemos encontrarlos en /var/spool/clientmqueue(path definido en /etc/mail/submit.cf)

NOTA: Yo no he conseguido de ninguna manera encolar mails en la cola clientmqueue, desconozco en que caso se dará dicho escenario.

Además en cada una de las colas pueden haber mails en distintos estados:

  • lost: Son los mails que tras varios intentos no se han podido entregar por alguna razón.
  • quarantined: Son los mails que por alguna razón han quedado en cuarentena, esto puede ocurrir por alguna regla definida a nivel de SendMail o por algún milter como SpamAssassin por ejemplo.

Mailq nos permite visuarlizar las distintas colas y estados según los argumentos que le pasemos:

  • Sin argumentos: Muestra la cola mqueue
  • -Ac: Muestra la cola clientmqueue
  • -qL: Muestra los mails lost
  • -qQ: Muestra los mails quarantined

Ahora que ya sabemos consultar los datos manualmente vamos a proceder con la programación de nuestro exporter, lo primero es decidir que tipo de métricas vamos a servir en mi caso serán todo Gauges:

  • Emails enviados/recibidos
  • Bytes enviados/recibidos
  • Email encolados por cola/estado

Instalamos las librerías necesarias:

pip install flask flask_httpauth prometheus_client waitress

Programamos nuestro exporter:

vi sendmail_exporter

#!/usr/local/bin/python
from flask import Response, Flask, request
from flask_httpauth import HTTPBasicAuth
from werkzeug.security import generate_password_hash, check_password_hash
from waitress import serve

import prometheus_client
from prometheus_client.core import CollectorRegistry
from prometheus_client import Gauge

import time
import threading
import subprocess
import re
import datetime

# https://github.com/prometheus/client_python#gauge
smtp_incoming_emails = Gauge('smtp_incoming_emails', 'Incoming emails via SMTP: mailstats')
smtp_incoming_data = Gauge('smtp_incoming_data', 'Incoming Kbytes via SMTP: mailstats')
smtp_incoming_rejected_emails = Gauge('smtp_incoming_rejected_emails', 'Rejected incoming emails via SMTP: mailstats')
smtp_incoming_discarded_emails = Gauge('smtp_incoming_discarded_emails', 'Discarded incoming emails via SMTP: mailstats')
smtp_incoming_quarantined_emails = Gauge('smtp_incoming_quarantined_emails', 'Quarantined incoming emails via SMTP: mailstats')

smtp_outcoming_emails = Gauge('smtp_outcoming_emails', 'outcoming emails via SMTP: mailstats')
smtp_outcoming_data = Gauge('smtp_outcoming_data', 'outcoming Kbytes via SMTP: mailstats')
smtp_outcoming_rejected_emails = Gauge('smtp_outcoming_rejected_emails', 'Rejected outcoming emails via SMTP: mailstats')
smtp_outcoming_discarded_emails = Gauge('smtp_outcoming_discarded_emails', 'Discarded outcoming emails via SMTP: mailstats')
smtp_outcoming_quarantined_emails = Gauge('smtp_outcoming_quarantined_emails', 'Quarantined outcoming emails via SMTP: mailstats')

smtp_queued_emails_mqueue = Gauge('smtp_queued_emails_mqueue', 'Queued emails: qmail')
smtp_queued_emails_clientmqueue = Gauge('smtp_queued_emails_clientmqueue', 'Queued emails: qmail -Ac')
smtp_queued_emails_lost = Gauge('smtp_queued_emails_lost', 'Queued emails: qmail -qL')
smtp_queued_emails_quarantined = Gauge('smtp_queued_emails_quarantined', 'Queued emails: qmail -qQ')

app = Flask(__name__)
auth = HTTPBasicAuth()
users = {
    "sendmail_exporter_user": generate_password_hash("PASSWORD"),
}

@auth.verify_password
def verify_password(username, password):
    if username in users and check_password_hash(users.get(username), password):
        return username

def get_sendmail_stats():
    print('++ mainThread started')
    print('------------------')
    while True:
        now = datetime.datetime.now()
        print('%s' % now)
        print('')

        # MAILSTATS:
        process = subprocess.run(["mailstats", "-P"], capture_output=True, encoding="utf-8")
        #print(process.stdout)
        for line in process.stdout.splitlines():
            search_pattern = False
            search_pattern = re.match("\s*\d*\s*\d*\s*\d*\s*(\d*)\s*(\d*)\s*(\d*)\s*(\d*)\s*(\d*)\s*local", line)
            if search_pattern:
                print('> smtp_incoming_emails: %i' % int(search_pattern.group(1)))
                smtp_incoming_emails.set(search_pattern.group(1))
                print('> smtp_incoming_data: %s' % search_pattern.group(2))
                smtp_incoming_data.set(search_pattern.group(2))
                print('> smtp_incoming_rejected_emails: %i' % int(search_pattern.group(3)))
                smtp_incoming_rejected_emails.set(search_pattern.group(3))
                print('> smtp_incoming_discarded_emails: %i' % int(search_pattern.group(4)))
                smtp_incoming_discarded_emails.set(search_pattern.group(4))
                print('> smtp_incoming_quarantined_emails: %i' % int(search_pattern.group(5)))
                smtp_incoming_quarantined_emails.set(search_pattern.group(5))
                print('')
                continue

            search_pattern = False
            search_pattern = re.match("\s*\d*\s*\d*\s*\d*\s*(\d*)\s*(\d*)\s*(\d*)\s*(\d*)\s*(\d*)\s*esmtp", line)
            if search_pattern:
                print('> smtp_outcoming_emails: %i' % int(search_pattern.group(1)))
                smtp_outcoming_emails.set(search_pattern.group(1))
                print('> smtp_outcoming_data: %s' % search_pattern.group(2))
                smtp_outcoming_data.set(search_pattern.group(2))
                print('> smtp_outcoming_rejected_emails: %s' % search_pattern.group(3))
                smtp_outcoming_rejected_emails.set(search_pattern.group(3))
                print('> smtp_outcoming_discarded_emails: %s' % search_pattern.group(4))
                smtp_outcoming_discarded_emails.set(search_pattern.group(4))
                print('> smtp_outcoming_quarantined_emails: %s' % search_pattern.group(5))
                smtp_outcoming_quarantined_emails.set(search_pattern.group(5))
                print('')
                continue

        # MAILQ:
        process = subprocess.run(["mailq"], capture_output=True, encoding="utf-8")
        for line in process.stdout.splitlines():
            search_pattern = False
            search_pattern = re.match(".*Total requests:\s*(\d*)", line)
            if search_pattern:
                print('> smtp_queued_emails_mqueue: %s' % search_pattern.group(1))
                smtp_queued_emails_mqueue.set(search_pattern.group(1))
                break

        process = subprocess.run(["mailq", "-Ac"], capture_output=True, encoding="utf-8")
        for line in process.stdout.splitlines():
            search_pattern = False
            search_pattern = re.match(".*Total requests:\s*(\d*)", line)
            if search_pattern:
                print('> smtp_queued_emails_clientmqueue: %s' % search_pattern.group(1))
                smtp_queued_emails_clientmqueue.set(search_pattern.group(1))
                break

        process = subprocess.run(["mailq", "-qL"], capture_output=True, encoding="utf-8")
        for line in process.stdout.splitlines():
            search_pattern = False
            search_pattern = re.match(".*Total requests:\s*(\d*)", line)
            if search_pattern:
                print('> smtp_queued_emails_lost: %s' % search_pattern.group(1))
                smtp_queued_emails_lost.set(search_pattern.group(1))
                break

        process = subprocess.run(["mailq", "-qQ"], capture_output=True, encoding="utf-8")
        for line in process.stdout.splitlines():
            search_pattern = False
            search_pattern = re.match(".*Total requests:\s*(\d*)", line)
            if search_pattern:
                print('> smtp_queued_emails_quarantined: %s' % search_pattern.group(1))
                smtp_queued_emails_quarantined.set(search_pattern.group(1))
                break

        # Metric refresh rate
        time.sleep(30)
        print('------------------')

@app.route("/metrics")
@auth.login_required

def serve_metrics():
    now = datetime.datetime.now()
    res = []
    src_ip = request.remote_addr
    print('')
    print('< Serving metrics: %s - %s' % (src_ip,now))
    print('')
    res.append(prometheus_client.generate_latest())
    return Response(res, mimetype="text/plain")

if __name__ == "__main__":
    mainThread = threading.Thread(target=get_sendmail_stats)
    mainThread.start()
    serve(app, host="0.0.0.0", port=2525)

NOTA: Recordad que si instalamos el exporter en el host padre de un servidor de jails deberemos bindearlo solo a su ip en concreto ya que si no lo hacemos así ocupará las ips de todas las jails.

Le damos permisos de ejecución:

chmod 700 sendmail_exporter

Lo ejecutamos manualmente para asegurarnos de que no falla:

./sendmail_exporter

Consultamos las métricas manualmente:

curl http://sendmail_exporter_user:PASSWORD@localhost:2525/metrics

# HELP python_gc_objects_collected_total Objects collected during gc
# TYPE python_gc_objects_collected_total counter
python_gc_objects_collected_total{generation="0"} 98.0
python_gc_objects_collected_total{generation="1"} 287.0
python_gc_objects_collected_total{generation="2"} 0.0
# HELP python_gc_objects_uncollectable_total Uncollectable object found during GC
# TYPE python_gc_objects_uncollectable_total counter
python_gc_objects_uncollectable_total{generation="0"} 0.0
python_gc_objects_uncollectable_total{generation="1"} 0.0
python_gc_objects_uncollectable_total{generation="2"} 0.0
# HELP python_gc_collections_total Number of times this generation was collected
# TYPE python_gc_collections_total counter
python_gc_collections_total{generation="0"} 77.0
python_gc_collections_total{generation="1"} 6.0
python_gc_collections_total{generation="2"} 0.0
# HELP python_info Python platform information
# TYPE python_info gauge
python_info{implementation="CPython",major="3",minor="9",patchlevel="16",version="3.9.16"} 1.0
# HELP smtp_incoming_emails Incoming emails via SMTP: mailstats
# TYPE smtp_incoming_emails gauge
smtp_incoming_emails 1.0
# HELP smtp_incoming_data Incoming Kbytes via SMTP: mailstats
# TYPE smtp_incoming_data gauge
smtp_incoming_data 5.0
# HELP smtp_incoming_rejected_emails Rejected incoming emails via SMTP: mailstats
# TYPE smtp_incoming_rejected_emails gauge
smtp_incoming_rejected_emails 0.0
# HELP smtp_incoming_discarded_emails Discarded incoming emails via SMTP: mailstats
# TYPE smtp_incoming_discarded_emails gauge
smtp_incoming_discarded_emails 0.0
# HELP smtp_incoming_quarantined_emails Quarantined incoming emails via SMTP: mailstats
# TYPE smtp_incoming_quarantined_emails gauge
smtp_incoming_quarantined_emails 0.0
# HELP smtp_outcoming_emails outcoming emails via SMTP: mailstats
# TYPE smtp_outcoming_emails gauge
smtp_outcoming_emails 1.0
# HELP smtp_outcoming_data outcoming Kbytes via SMTP: mailstats
# TYPE smtp_outcoming_data gauge
smtp_outcoming_data 2.0
# HELP smtp_outcoming_rejected_emails Rejected outcoming emails via SMTP: mailstats
# TYPE smtp_outcoming_rejected_emails gauge
smtp_outcoming_rejected_emails 0.0
# HELP smtp_outcoming_discarded_emails Discarded outcoming emails via SMTP: mailstats
# TYPE smtp_outcoming_discarded_emails gauge
smtp_outcoming_discarded_emails 0.0
# HELP smtp_outcoming_quarantined_emails Quarantined outcoming emails via SMTP: mailstats
# TYPE smtp_outcoming_quarantined_emails gauge
smtp_outcoming_quarantined_emails 0.0
# HELP smtp_queued_emails_mqueue Queued emails: qmail
# TYPE smtp_queued_emails_mqueue gauge
smtp_queued_emails_mqueue 0.0
# HELP smtp_queued_emails_clientmqueue Queued emails: qmail -Ac
# TYPE smtp_queued_emails_clientmqueue gauge
smtp_queued_emails_clientmqueue 0.0
# HELP smtp_queued_emails_lost Queued emails: qmail -qL
# TYPE smtp_queued_emails_lost gauge
smtp_queued_emails_lost 0.0
# HELP smtp_queued_emails_quarantined Queued emails: qmail -qQ
# TYPE smtp_queued_emails_quarantined gauge
smtp_queued_emails_quarantined 0.0

Copiamos el exporter a un path mas conveniente:

cp sendmail_exporter /usr/local/sbin/

Demonizamos el exporter:

vi /usr/local/etc/rc.d/sendmail_exporter

#! /bin/sh
#
# $FreeBSD$
#

# PROVIDE: sendmail_exporter
# REQUIRE: DAEMON
# KEYWORD: shutdown 

. /etc/rc.subr

name="sendmail_exporter"
rcvar="${name}_enable"
extra_commands="status"

start_cmd="${name}_start"
stop_cmd="${name}_stop"
status_cmd="${name}_status"

sendmail_exporter_start(){
    echo "Starting service: ${name}"
    /usr/sbin/daemon -S -p /var/run/${name}.pid -T ${name} /usr/local/sbin/sendmail_exporter
}

sendmail_exporter_stop(){
    if [ -f /var/run/${name}.pid ]; then
        echo "Stopping service: ${name}"
        kill $(cat /var/run/${name}.pid)
	sleep 4
    else
        echo "It appears ${name} is not running."
    fi
}

sendmail_exporter_status(){
    if [ -f /var/run/${name}.pid ]; then
        echo "${name} running with PID: $(cat /var/run/${name}.pid)"
    else
        echo "It appears ${name} is not running."
    fi
}

load_rc_config ${name}
run_rc_command "$1"

Asignamos los permisos necesarios a nuestro script RC:

chmod 755 /usr/local/etc/rc.d/sendmail_exporter

Habilitamos el servicio:

sysrc sendmail_exporter_enable=YES

Arrancamos el exporter:

service sendmail_exporter start

Consultamos de nuevo las métricas para asegurarnos de que sigue funcionando:

curl http://sendmail_exporter_user:PASSWORD@localhost:2525/metrics

Para poder configurar los scrapes por nombre debo dar de alta los servidores a monitorizar en el fichero /etc/hosts del servidor Prometheus ya que no tengo un servidor DNS en mi red local:

vi /etc/hosts

192.168.69.2 		mightymax
192.168.69.4		garrus
192.168.69.16		baudbeauty
192.168.69.17		hellstorm
192.168.69.18		paradox
192.168.69.19		atlas
192.168.69.20		metacortex

Damos de alta el scrape en Prometheus:

vi /usr/local/etc/prometheus.yml

  - job_name: 'sendmail_exporter'
    scrape_interval: 30s
    static_configs:
      - targets:
        - hellstorm:2525
        labels:
          scrape_interval: 30s
    basic_auth:
      username: sendmail_exporter_user
      password: PASSWORD

NOTA: Si cambiamos el intervalo de scrape es importante hacerlo también en la etiqueta scrape_interval de las gráficas de Grafana ya que se utiliza en las querys de las gráficas.

Reiniciamos el servicio:

service prometheus restart

Importamos la siguiente dashboard en Grafana

Donde veremos las siguientes gráficas:

Si te ha gustado el artículo puedes invitarme a un RedBull aquí