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:
cd /etc/mail
Generamos el fichero de configuración:
Lo editamos:
define(`STATUS_FILE',`/var/log/sendmail.stats')dnl
Compilamos la configuración y la aplicamos:
cp HOSTNAME.cf sendmail.cf
Reiniciamos el servicio:
Ahora mediante el siguiente comando podemos obtener las estadísticas:
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 00011 deny tcp from any to any 25
Consultamos la cola:
/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 00011
/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:
Programamos nuestro 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:
Lo ejecutamos manualmente para asegurarnos de que no falla:
Consultamos las métricas manualmente:
# 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:
Demonizamos el 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:
Habilitamos el servicio:
Arrancamos el exporter:
Consultamos de nuevo las métricas para asegurarnos de que sigue funcionando:
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:
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:
- 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:
Importamos la siguiente dashboard en Grafana
Donde veremos las siguientes gráficas: