Projet

Général

Profil

Demande #3021 » check_bounces.py

François Poulain, 06/09/2019 15:28

 
#!/usr/bin/env python3

# Copyright (C) 2018 François Poulain <fpoulain@metrodore.fr>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""
Parse mail.log and count sending statuses by domains in order to detect
delivrability issues and impacted domains.
"""

# TODO:
# [ ] intégrer comparatif par rapport à la période précédente
# [ ] regrouper par relais

import argparse, datetime, re, json
from itertools import groupby

logfiles = ['mail.log.1', 'mail.log']
cachefile = '/tmp/check_bounces.json'

# ======================================
# Argument parsing
# ======================================

class ConstraintsAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
setattr(namespace, self.dest, values)
vals= vars(namespace)
if vals['warning'] and vals['critical'] and vals['warning'] > vals['critical']:
raise argparse.ArgumentError (self,
'critical threshold should be greater than warning threshold')

parser = argparse.ArgumentParser(description="""
Parse mail.log and count sending statuses by domains in order to detect
delivrability issues and impacted domains.
""")

ma= parser.add_argument_group(title='mandatory arguments', description=None)
ma.add_argument('-C', '--critical', metavar='THRESH', type=int, required= True,
action=ConstraintsAction, help='Critical threshold')
ma.add_argument('-W', '--warning', metavar='THRESH', type=int, required= True,
action=ConstraintsAction, help='Warning threshold')

parser.add_argument('-D', '--domains', metavar='DOMAIN.TLD', type=str, nargs='+',
help='Warn when given domain(s) appears in top bounced domains.')
parser.add_argument('-N', '--number', metavar='INT', type=int, default= 3,
help='Number of considered bounced domains domains. Default is 3.')
parser.add_argument('-v', '--verbose', default=0, action='count',
help='Verbose output. -vv for very verbose output')
parser.add_argument('-V', '--version', action='version', version='%(prog)s 0.1')

args = parser.parse_args()

# ======================================
# log parsing and data processing
# ======================================


def parse_log (log, stamp):
return re.findall(
r'^' + stamp + ' (\d\d:\d\d:\d\d) .*([0-9A-F]{9}): to=<([^@]+@[^>]+)>.*relay=([^,]+), .*status=([a-z]+)',
log,
re.MULTILINE,
)


def name_fields (l):
return [
{
'time': m[0],
'id': m[1],
'domain': m[2],
'relay': m[3],
'status': m[4],
}
for m in l
]


def remove_local_relays(l):
return [
s for s in l
if s['relay'] not in ['none', 'local']
and
'127.0.0.1' not in s['relay']
and
'172.16.0.' not in s['relay']
]


def drop_retries (l):
return l


def resolve_domains(l):
def revert_srs(s):
if re.match(r'^srs0=', s):
return re.match(r'^srs\d+=(?P<hash>[^=]+)=(?P<tt>[^=]+)=(?P<domain>[^=]+)=', s).group('domain')
elif re.match(r'^srs\d+=', s):
return re.match(r'^srs\d+=(?P<hash>[^=]+)=(?P<domain>[^=]+)=', s).group('domain')
else:
return re.match(r'^[^@]+@([^>]+)$', s).group(1)
return [{**d, 'domain':revert_srs(d['domain'])} for d in l]


def regroupby(l, key):
keyfun = lambda x:x[key]
l = [(x, [{k:v for k,v in d.items() if k != key} for d in y]) for x,y in groupby(sorted(l, key=keyfun), key=keyfun)]
return [{key:x, 'count':len(l), 'list':l} for x,l in l]

# ======================================
# Main call
# ======================================

status_output= ['OK', 'WARN', 'CRIT', 'UNK']


def get_by_domains_by_status():
yesterday = datetime.date.today() - datetime.timedelta(1)
yesterday_stamp = yesterday.strftime('%b %d').replace(' 0', ' ')

try:
with open(cachefile) as f:
return json.load(f)[yesterday_stamp]
except:
log = ""
for logfile in logfiles:
try:
with open(logfile) as f:
log += f.read()
except:
pass
if log == "":
raise ValueError("No logfile found")

statuses = parse_log(log, yesterday_stamp)
statuses = name_fields(statuses)
statuses = remove_local_relays(statuses)
statuses = drop_retries(statuses)
statuses = resolve_domains(statuses)
by_domains = regroupby(statuses, 'domain')

by_domains_by_status = [{**d, 'list':regroupby(d['list'], 'status')} for d in by_domains]

by_domains_by_status = [
{
**d, 'list':[{**e, 'list':regroupby(d['list'], 'relay')} for e in regroupby(d['list'], 'status') ],
}
for d in by_domains
]

with open(cachefile, 'w') as f:
json.dump({yesterday_stamp:by_domains_by_status}, f, indent=2)

return by_domains_by_status


try:
by_domains_by_status = get_by_domains_by_status()

scores = []
for domain in by_domains_by_status:
sents, bounces, defers = 0, 0, 0
for status in domain['list']:
if status['status'] == 'sent':
sents = status['count']
if status['status'] == 'bounced':
bounces = status['count']
if status['status'] == 'deferred':
defers = status['count']

scores.append({'domain':domain['domain'], 'sent':sents, 'bounced':bounces, 'deferred':defers})

scores = sorted(scores, key=lambda x:(x['bounced'], x['deferred'], x['sent']), reverse=True)[:args.number]

perfsdata= 'bounced={} deferred={} sent={}'.format(
sum([s['bounced'] for s in scores]),
sum([s['deferred'] for s in scores]),
sum([s['sent'] for s in scores]),
)

bounces = sum([s['bounced'] for s in scores])

if bounces > args.critical:
status= 2
elif bounces > args.warning:
status= 1
else:
status= 0
for destination in scores:
if args.domains and destination['domain'] in args.domains:
status= 1
print ('check_bounces:', status_output[status], 'Total bounces:', bounces, '|', perfsdata)
if args.verbose > 1:
print(json.dumps([dest for dest in by_domains_by_status if dest['domain'] in [d['domain'] for d in scores]], indent=2))
elif args.verbose > 0:
for domain in scores:
print (domain)
except Exception as e:
print ('exception occured: {}'.format(e))
exit (3)

exit (status)
(2-2/2)