csbackntfy.py 16.9 KB
Newer Older
1
#!/usr/bin/env python
2
## @file csbackntfy.py
3
4
# @brief Checking the log entries and send a email containing the current
# status.
5
6
7
8
9
# 
# -----------------------------------------------------------------------------
# 
# $Id$
# @author Daniel Armbruster
10
# \date 05/01/2012
11
# 
12
13
# Purpose: Checking the log entries and send a email containing the current
# status.
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#
# ----
# This file is part of csback.
#
# csback 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.
# 
# csback 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 csback.  If not, see <http://www.gnu.org/licenses/>.
# ----
# 
32
# Copyright (c) 2012 by Daniel Armbruster
33
34
# 
# REVISIONS and CHANGES 
35
36
37
38
39
40
41
42
43
44
45
# 05/01/2012  V0.1    Daniel Armbruster
# 12/01/2012  V0.2    provide nagios status line
# 10/05/2012  V0.3    provide output to stdout
# 18/05/2012  V0.4    provide information regarding possible message
#                     criticalities (thof)
# 19/05/2012  V0.5    Bug fixed - str.find didn't work as expected.
# 10/12/2012  V0.6    provide '-D ARG' option to list only most recent error
#                     messages 
# 23/01/2013  V0.7    consider additional csback status file
# 01/03/2013  V0.8    Improve error handling and use error codes
# 17/03/2013  V0.8.1  show number of critical and error entries in mail
46
# 01/09/2014  V0.9    (thof) use local mail transfer agent
47
48
# 02/08/2019  V0.10   (thof) print reasonable error meassage if non of the
#                            message level keywords is found
49
50
# 
# =============================================================================
51
"""
52
Script to check csback log files and send an email.
53
"""
54
 
55
56
57
58
59
60
import getopt
import os
import sys
import re
import smtplib
import logging
61
62
import getpass
import socket
63
import csbacklog
64
import datetime as dt
65
import csbackErrorCodes as eCodes
66

67
68
from email.mime.text import MIMEText
from collections import deque
69

70
__version__ = "V0.10"
71
__subversion__ = "$Id$"
72
__license__ = "GPLv2+"
73
74
75
76
__author__ = "Daniel Armbruster"
__copyright__ = "Copyright (c) 2012 by Daniel Armbruster"

# -----------------------------------------------------------------------------
77
78
79
# variables
# ---------
USAGE_TEXT = "Version: "+__version__+"\nLicense: "+__license__+ \
80
      "\n"+__subversion__+"\nAuthor: "+__author__+ """
81
82
83
 Usage: csbackntfy [-v|--verbose] [-d|--debug] [-l|--logging] [-D|--days ARG] 
                   [-S|--Status ARG] -P|--port ARG -H|--host ARG
                   -u|--username ARG -p|--password ARG 
Daniel Armbruster's avatar
Daniel Armbruster committed
84
                   -r|--receiver ADDRESS [-r|--receiver ADDRESS [...]]
85
                   -s|--sender ADDRESS [PATH [PATH [...]]]
86
87
88
89
    or: csbackntfy [-v|--verbose] [-d|--debug] [-l|--logging] [-D|--days ARG] 
                   [-S|--Status ARG] -n|--nagios [PATH [PATH [...]]]
    or: csbackntfy [-v|--verbose] [-d|--debug] [-l|--logging] [-D|--days ARG]
                   [-S|--Status ARG] -o|--out [PATH [PATH [...]]]
90
    or: csbackntfy -h|--help\n"""
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111

# -----------------------------------------------------------------------------
# exception handling
# ---------
class Error(Exception):

  def __init__(self, msg, errorCode):
    self.msg = str(msg)
    self.errorCode = int(errorCode)

  def __str__(self):
    return "csbackobs (ERROR): {0} (CODE {1}).\n".format(self.msg, \
        self.errorCode)


class Usage(Error):

  def __str__(self):
    return "csbackobs (ERROR): {0} (CODE {1}).\n".format( \
        self.msg, self.errorCode)+USAGE_TEXT

112
113
114
115
116
117

# -----------------------------------------------------------------------------
def help():
  """
  Printing helptext to stdout.
  """
118
  help_text = USAGE_TEXT+"""
119
120
121
122
-------------------------------------------------------------------------------
 -v|--verbose       Be verbose.
 -h|--help          Display this help.
 -d|--debug         Debug mode. Be really verbose.
123
124
 -l|--logging       Switch on logging to logfile. Logfile(s) will be located in
                    /var/log/ .
125
 -r|--receiver ADDR Email address(es) of the receiver(s). (obligatory)
Daniel Armbruster's avatar
Daniel Armbruster committed
126
 -s|--sender ADDR   Email address of the sender (Sending over SMTP).
127
                    (obligatory)
Daniel Armbruster's avatar
Daniel Armbruster committed
128
129
 -P|--port ARG      SMTP port.
 -H|--host ARG      Hostname of the SMTP server.
130
 -t|--tls           Use TLS encryption.
Daniel Armbruster's avatar
Daniel Armbruster committed
131
132
 -u|--username ARG  Username for SMTP server login.
 -p|--password ARG  Password for SMTP server login.
133
134
 -n|--nagios        Use csbackntfy in it's nagios mode and print the current 
                    status in a simple line to stdout.
135
 -o|--out           Send output to stdout instead of sending an email.
136
137
138
 -D|--days ARG      List only WARNING/ERROR/CRITICAL messages of last ARG days.
                    If ARG is equal to 0 (zero) then all messages are listed
                    (default).
139
140
141
142
143
144
 -S|--Status ARG    Take additional csback status file into consideration. The
                    path to the status file is ARG. If a csback status file
                    contains CRITICAL entries the status of the mail always is
                    set to CRITICAL. After resolving the issues the content of
                    the file should be removed such that the actual notification
                    status only depends on logfile(s).
145
 PATH               Path(s) of the logfile(s) to check. If not specified the
146
                    logfiles in /var/log/ were investigated.
147
-------------------------------------------------------------------------------
148
 csback distiguishes four different levels of message criticality:
149
150
151
152
153
154
155
156
   INFO:     indicates normal operation
   WARNING:  indicates any problems which were expected and are tolerated
   CRITICAL: indicates a checksum mismatch
   ERROR:    indicates errors (missing files, etc)
 For INFO and WARNING the ten most recent messages are reported. For CRITICAL
 and ERROR all messages currently present in the log file are reported.
 The nagios report is "CSBACK OK" if no messages of levels WARNING, CRITICAL,
 or ERROR are present.
157
158
159
160
161
162
"""
  sys.stdout.write(help_text)

# -----------------------------------------------------------------------------
def main(argv=None):
  # configure logger
163
  logger = csbacklog.CsbackLog('csbackntfy')
164
165
166
167
168
169
170
171
172
173
  
  console = logging.StreamHandler()
  console.setFormatter(logging.Formatter( \
    '%(name)-8s [%(lineno)d]: %(levelname)-8s %(message)s'))

  # fetch arguments
  if argv is None:
    argv = sys.argv
  try:
    try:
174
      opts, args = getopt.getopt(argv[1:], "vhdlr:s:P:H:u:p:ntoD:S:", ["help", \
Daniel Armbruster's avatar
Daniel Armbruster committed
175
        "verbose", "debug", "logging", "receiver=", "sender=", "port=", \
176
177
        "host=", "username=", "password=", 'nagios', 'tls', 'out', 'days', \
        "Status="])
178
    except getopt.GetoptError as err:
179
      raise Usage(err.msg, eCodes.GLOBAL_UsageError)
180
181
182
    verbose = False
    debugMode = False
    receivers = []
183
184
    sender = getpass.getuser() + '@' \
      + socket.gethostbyaddr(socket.gethostname())[0]
185
186
    port = 25
    host = 'localhost'
Daniel Armbruster's avatar
Daniel Armbruster committed
187
188
    username = ''
    password = ''
189
    nagiosMode = False
190
    useTLS = False
191
    stdoutMode = False
192
    daysToList=0
193
    useCsbackStatusFile = False
194

195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
    # collect commandline arguments
    for opt, arg in opts:
      if opt in ("-v", "--verbose"):
        verbose = True
        console.setLevel(logging.INFO)
      elif opt in ("-h", "--help"):
        sys.exit(help())
        return 0
      elif opt in ("-d", "--debug"):
        debugMode = True
        console.setLevel(logging.DEBUG)
      elif opt in ("-l", "--logging"):
        logger.configure()
      elif opt in ("-r", "--receiver"):
        receivers.append(arg)
      elif opt in ("-s", "--sender"):
        sender = arg
Daniel Armbruster's avatar
Daniel Armbruster committed
212
213
214
215
216
217
218
219
      elif opt in ("-P", "--port"):
        port = int(arg)
      elif opt in ("-H", "--host"):
        host = arg
      elif opt in ("-u", "--username"):
        username = arg
      elif opt in ("-p", "--password"):
        password = arg
220
221
      elif opt in ("-n", "--nagios"):
        nagiosMode = True
222
223
      elif opt in ("-t", "--tls"):
        useTLS = True
224
225
      elif opt in ("-o", "--out"):
        stdoutMode = True
226
227
228
      elif opt in ("-S", "--Status"):
        useCsbackStatusFile = True
        csbackStatusFile = arg
229
230
231
      elif opt in ("-D", "--days"):
        daysToList = int(arg)
        if daysToList < 0:
232
233
          raise Usage("Invalid argument passed. Value must be equal to or \
              \t       greater than 0.", eCodes.GLOBAL_InvalidCmdlineArgs)
234
      else:
235
        raise Usage("Unhandled option chosen.", eCodes.GLOBAL_UnhandledOption)
236
237
238
239
240
241

    if verbose or debugMode:
      logger.addHandler(console)

    if 1 <= len(args):
      logger.getLogger().info("Taking passed PATH(s) as arguments.")
242
      logFilePathes = [str(arg).rstrip(os.sep) for arg in args]
243
244
    # fetch default logfiles
    elif 0 == len(args):
245
      logger.getLogger().info("Taking default logfiles in /var/log/")
246
247
      logFilePathes = [os.path.join(csbacklog.LOGFILEDIR, logfile) \
        for logfile in os.listdir(csbacklog.LOGFILEDIR) \
248
        if None != re.match('^csback\.log$', logfile)]
Daniel Armbruster's avatar
Daniel Armbruster committed
249
      logFilePathes.reverse()
250
    else:
251
      raise Usage("Invalid argument(s).", eCodes.GLOBAL_InvalidCmdlineArgs)
252
253
254
255
256
257
258

    # compute time limit for WARNING/ERROR/CRITICAL messages to display
    now=dt.datetime.now() 
    if daysToList != 0:
      timeLimit=now-dt.timedelta(days=daysToList)
    else:
      timeLimit=dt.datetime.min
259

Daniel Armbruster's avatar
Daniel Armbruster committed
260
261
    # use deques so that only the last ten INFO and WARNING messages were
    # printed to the mail
262
263
    infoDeque = deque(maxlen=10)
    warningDeque = deque(maxlen=10) 
Daniel Armbruster's avatar
Daniel Armbruster committed
264
265
    # instead of a deque use lists for ERROR and CRITICAL messages here to print
    # the entire information to the mail
266
267
268
269
270
271
    errorList = []
    criticalList = []
    # read logfiles
    logger.getLogger().info("Reading logfile(s) ...")
    try:
      for path in logFilePathes:
272
        for line in open(path, 'r').readlines():
273
274
275
          # extract timestamp from log message
          timeStamp=dt.datetime.strptime(str(now.year)+" "+" ".join(str(n) \
              for n in line.split()[0:3]),"%Y %b %d %H:%M:%S")
Daniel Armbruster's avatar
Daniel Armbruster committed
276
          if 'CRITICAL' in line:
277
            if timeStamp > timeLimit:
278
              criticalList.append("logfile: "+path+"\n"+line) 
Daniel Armbruster's avatar
Daniel Armbruster committed
279
          elif 'ERROR' in line:
280
            if timeStamp > timeLimit:
281
              errorList.append("logfile: "+path+"\n"+line)
Daniel Armbruster's avatar
Daniel Armbruster committed
282
          elif 'WARNING' in line:
283
            if timeStamp > timeLimit:
284
              warningDeque.appendleft("logfile: "+path+"\n"+line)
Daniel Armbruster's avatar
Daniel Armbruster committed
285
          elif 'INFO' in line:
286
            infoDeque.appendleft("logfile: "+path+"\n"+line)
287
          else:
288
289
290
291
            logger.getLogger().info(("Illegal line in logfile found\n"
              +"  Line does not contain any of the csback message level keywords\n"
              +"  %s %s %s %s\n  The log line is:\n  %s")
              % ("CRITICAL", "ERROR", "WARNING", "INFO", line))
292
    except IOError as err:
293
294
      raise Error("Error while reading logfiles: "+err.filename, \
          eCodes.NTFY_IOError)
295
296
    logger.getLogger().debug("Finished reading logfile(s).")

297
298
299
300
301
302
    # take status file into consideration
    if useCsbackStatusFile:
      logger.getLogger().info("Taking csback status file into consideration.")
      try:
        for line in open(csbackStatusFile, 'r').readlines():
          if 'CRITICAL' in line:
Daniel Armbruster's avatar
Daniel Armbruster committed
303
304
            criticalList.append("CSBACK STATUS FILE: "+csbackStatusFile+ \
              "\n"+line) 
305
306
307
      except:
        logger.getLogger().info("csback status file not available.")

Daniel Armbruster's avatar
Daniel Armbruster committed
308
    # if nagios mode had been enabled just print the status to stdout
309
310
    if nagiosMode:
      if len(criticalList):
Daniel Armbruster's avatar
Daniel Armbruster committed
311
        sys.stdout.write("CSBACK CRITICAL\n")
312
      elif len(errorList):
Daniel Armbruster's avatar
Daniel Armbruster committed
313
        sys.stdout.write("CSBACK ERROR\n")
314
      elif len(warningDeque):
Daniel Armbruster's avatar
Daniel Armbruster committed
315
        sys.stdout.write("CSBACK WARNING\n")
316
      else:
Daniel Armbruster's avatar
Daniel Armbruster committed
317
        sys.stdout.write("CSBACK OK\n")
318
    # generate the email using pythons smtplib or write to stdout
319
    else:
320
321
322
      if not stdoutMode:
        logger.getLogger().debug("Checking email addresses.")
        if 0 == len(sender):
323
324
          raise Usage("Email address of sender missing.", \
              eCodes.GLOBAL_UsageError)
325
        if None == re.match( \
326
          '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$', sender):
327
328
          raise Error("Email address of sender not valid.", \
              eCodes.NTFY_InvalidMailAdd)
329
        if 0 == len(receivers):
330
331
          raise Usage("Email address of receiver missing.", \
              eCodes.GLOBAL_UsageError)
332
333
334
        for add in receivers:
          if None == re.match( \
            '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$', add):
335
336
            raise Error("Email address of receiver not valid.", \
                eCodes.NTFY_InvalidMailAdd)
337

338
        if 0 == len(host):
339
          raise Usage("Hostname missing.", eCodes.GLOBAL_UsageError)
340
341
342
        if 0 != len(username):
          if 0 == len(password):
            raise Usage("Password missing.", eCodes.GLOBAL_UsageError)
343
        if -1 == port:
344
          raise Usage("Port missing.", eCodes.GLOBAL_UsageError)
345
      # prepare email
346
      logger.getLogger().debug("Preparing content ...")
347
348
349
350
351
352
353
      msg = '============================\n'
      msg += 'csbackntfy.py logfile status\n'
      msg += '============================\n'
      msg += 'Number of read logfiles: {0}\n\n'.format(len(logFilePathes))
      infoList = list(infoDeque)
      subject = ''
      if len(infoList):
354
        logger.getLogger().debug("Adding INFO logfile lines to content.")
355
356
357
358
359
360
361
362
363
        subject = 'csback report - severity: INFO'
        msg += '--------------------------\n'
        msg += 'INFO entries (last {0}):\n'.format(len(infoList))
        msg += '--------------------------\n'
        for line in infoList:
          msg += line
      warningList = list(warningDeque)
      if len(warningList):
        logger.getLogger().debug( \
364
          "Adding WARNING logfile lines to content.")
365
366
367
368
369
370
371
        subject = 'csback report - severity: WARNING'
        msg += '--------------------------\n'
        msg += 'WARNING entries (last {0}):\n'.format(len(warningList))
        msg += '--------------------------\n'
        for line in warningList:
          msg += line
      if len(errorList):
372
        logger.getLogger().debug("Adding ERROR logfile lines to content.")
373
374
        subject = 'csback report - severity: ERROR'
        msg += '--------------------------\n'
375
        msg += 'ERROR entries ({0}):\n'.format(len(errorList))
376
377
378
379
380
        msg += '--------------------------\n'
        for line in errorList:
          msg += line
      if len(criticalList):
        logger.getLogger().debug( \
381
          "Adding CRITICAL logfile lines to content.")
382
383
        subject = 'csback report - severity: CRITICAL'
        msg += '--------------------------\n'
384
        msg += 'CRITICAL entries ({0}):\n'.format(len(criticalList))
385
386
387
        msg += '--------------------------\n'
        for line in criticalList:
          msg += line
388
389
390
391
392
393
394
395
396
397
398
399
400
401
      
      if stdoutMode:
        # write output to stdout
        sys.stdout.write('Subject: '+subject+'\n\n')
        sys.stdout.write(msg+'\n')
      else:
        # send mail
        mail = MIMEText(msg)
        mail['Subject'] = subject
        mail['From'] = 'csback <'+sender+'>'
        mail['To'] = receivers[0]
        if 1 < len(receivers):
          mail['CC'] = ', '.join(receivers[1:])
        logger.getLogger().debug("Finished preparation of email content.")
402

403
404
405
        logger.getLogger().debug("Sending email.")
        try:
          session = smtplib.SMTP(host=host, port=port)
406
          session.ehlo()
407
408
409
          if useTLS:
            session.starttls()
            session.ehlo()
410
411
          if 0 != len(username):
            session.login(username, password)
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
          session.sendmail(sender, receivers, mail.as_string())
        except smtplib.SMTPRecipientsRefused:
          session.quit()
          logger.getLogger().warning( \
            "All recipients were refused. Nobody got the mail.")
        except smtplib.SMTPHeloError:
          session.quit()
          logger.getLogger().warning( \
            "The server didn't reply properly to the HELO greeting.")
        except smtplib.SMTPSenderRefused:
          session.quit()
          logger.getLogger().warning( \
            "The server didn't accept the address '%s'", sender)
        except smtplib.SMTPDataError:
          session.quit()
          logger.getLogger().warning( \
            "The server replied with an unexpected error code.")
        else:  
          session.quit()
431
432
      
  except Usage as err:
433
434
    sys.stderr.write(str(err))
    return err.errorCode
435
  except Error as err:
436
437
438
    logger.getLogger().error("message: %s [CODE %s]", err.msg, err.errorCode)
    sys.stderr.write(str(err))
    return err.errorCode
439
  else:
440
    if not nagiosMode and not stdoutMode:
Daniel Armbruster's avatar
Daniel Armbruster committed
441
      logger.getLogger().info("Email sent.")
442
443
444
445
446
447
    return 0

# -----------------------------------------------------------------------------
if __name__ == "__main__":
  sys.exit(main())

448
# ----- END OF csbackntfy.py -----