|
#!/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)
|