csbackntfy.py 17.2 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
# 
# -----------------------------------------------------------------------------
# 
# @author Daniel Armbruster
9
# \date 05/01/2012
10
# 
11 12
# Purpose: Checking the log entries and send a email containing the current
# status.
13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
#
# ----
# 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/>.
# ----
# 
31
# Copyright (c) 2012 by Daniel Armbruster
32 33
# 
# REVISIONS and CHANGES 
34 35 36 37 38 39 40 41 42 43 44
# 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
45
# 01/09/2014  V0.9    (thof) use local mail transfer agent
46 47
# 02/08/2019  V0.10   (thof) print reasonable error meassage if non of the
#                            message level keywords is found
48
#                     (thof) catch parsing error in date section of log line
49
#             V0.11   (thof) gracefully skip ill-formatted log lines
50 51
# 
# =============================================================================
52
"""
53
Script to check csback log files and send an email.
54
"""
55
 
56 57 58 59 60 61
import getopt
import os
import sys
import re
import smtplib
import logging
62 63
import getpass
import socket
64
import csbacklog
65
import datetime as dt
66
import csbackErrorCodes as eCodes
67

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

71
__version__ = "V0.11"
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
      "\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 
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)
126
 -s|--sender ADDR   Email address of the sender (Sending over SMTP).
127
                    (obligatory)
128 129
 -P|--port ARG      SMTP port.
 -H|--host ARG      Hostname of the SMTP server.
130
 -t|--tls           Use TLS encryption.
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", \
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'
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
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)]
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

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) 
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
          # extract timestamp from log message
274 275
          try:
            timeStamp=dt.datetime.strptime(str(now.year)+" "+" ".join(str(n) 
276
              for n in line.split()[0:3]),"%Y %b %d %H:%M:%S")
277
          except:
278 279
            logger.getLogger().error("Error while parsing log line: '%s'" 
                                      % line)
280
          else:
281 282 283 284 285 286 287 288 289 290 291 292 293 294 295
            if 'CRITICAL' in line:
              if timeStamp > timeLimit:
                criticalList.append("logfile: "+path+"\n"+line) 
            elif 'ERROR' in line:
              if timeStamp > timeLimit:
                errorList.append("logfile: "+path+"\n"+line)
            elif 'WARNING' in line:
              if timeStamp > timeLimit:
                warningDeque.appendleft("logfile: "+path+"\n"+line)
            elif 'INFO' in line:
              infoDeque.appendleft("logfile: "+path+"\n"+line)
            else:
              logger.getLogger().info("Illegal line in logfile found")
              logger.getLogger().info("  Line does not contain any of the csback message level keywords")
              logger.getLogger().info("  The log line is:  '%s'" % line)
296
    except IOError as err:
297 298
      raise Error("Error while reading logfiles: "+err.filename, \
          eCodes.NTFY_IOError)
299 300
    logger.getLogger().debug("Finished reading logfile(s).")

301 302 303 304 305 306
    # 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
307 308
            criticalList.append("CSBACK STATUS FILE: "+csbackStatusFile+ \
              "\n"+line) 
309 310 311
      except:
        logger.getLogger().info("csback status file not available.")

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

342
        if 0 == len(host):
343
          raise Usage("Hostname missing.", eCodes.GLOBAL_UsageError)
344 345 346
        if 0 != len(username):
          if 0 == len(password):
            raise Usage("Password missing.", eCodes.GLOBAL_UsageError)
347
        if -1 == port:
348
          raise Usage("Port missing.", eCodes.GLOBAL_UsageError)
349
      # prepare email
350
      logger.getLogger().debug("Preparing content ...")
351 352 353 354 355 356 357
      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):
358
        logger.getLogger().debug("Adding INFO logfile lines to content.")
359 360 361 362 363 364 365 366 367
        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( \
368
          "Adding WARNING logfile lines to content.")
369 370 371 372 373 374 375
        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):
376
        logger.getLogger().debug("Adding ERROR logfile lines to content.")
377 378
        subject = 'csback report - severity: ERROR'
        msg += '--------------------------\n'
379
        msg += 'ERROR entries ({0}):\n'.format(len(errorList))
380 381 382 383 384
        msg += '--------------------------\n'
        for line in errorList:
          msg += line
      if len(criticalList):
        logger.getLogger().debug( \
385
          "Adding CRITICAL logfile lines to content.")
386 387
        subject = 'csback report - severity: CRITICAL'
        msg += '--------------------------\n'
388
        msg += 'CRITICAL entries ({0}):\n'.format(len(criticalList))
389 390 391
        msg += '--------------------------\n'
        for line in criticalList:
          msg += line
392 393 394 395 396 397 398 399 400 401 402 403 404 405
      
      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.")
406

407 408 409
        logger.getLogger().debug("Sending email.")
        try:
          session = smtplib.SMTP(host=host, port=port)
410
          session.ehlo()
411 412 413
          if useTLS:
            session.starttls()
            session.ehlo()
414 415
          if 0 != len(username):
            session.login(username, password)
416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434
          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()
435 436
      
  except Usage as err:
437 438
    sys.stderr.write(str(err))
    return err.errorCode
439
  except Error as err:
440 441 442
    logger.getLogger().error("message: %s [CODE %s]", err.msg, err.errorCode)
    sys.stderr.write(str(err))
    return err.errorCode
443
  else:
444
    if not nagiosMode and not stdoutMode:
Daniel Armbruster's avatar
Daniel Armbruster committed
445
      logger.getLogger().info("Email sent.")
446 447 448 449 450 451
    return 0

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

452
# ----- END OF csbackntfy.py -----