#!/usr/bin/env python3

'''
        Icinga (Nagios) plugin that checks the status of OSPF neighbors on a Cisco IOS Router.

        The check returns OK if the neighbor state is 2WAY or FULL.
        Without any optional arguments, returns OK if any OSPF neighbors are detected.

        Optional arguments can be passed to match a specific neighbor Router ID (RID) or interface IP to look for.
        In that case a CRITICAL will be generated if that specific neighbor is down.

        In case multiple IP's or RID's are provided, a WARNING is generated if any of them is not 2WAY or FULL.
        If you set both IP's and RID's, only the IP's will be checked.
'''

__version__ = 'v0.22'
__author__ = 'raoul@node00.nl'

import sys
import argparse
import subprocess
import re


def ok(msg):
    print('OK:', msg)
    sys.exit(0)


def warning(msg):
    print('WARNING:', msg)
    sys.exit(1)


def critical(msg):
    print('CRITICAL:', msg)
    sys.exit(2)


def unknown(msg):
    print('UNKNOWN:', msg)
    sys.exit(3)


def error(msg):
    print('ERROR:', msg)
    sys.exit(3)


def check_ospf(snmp_check_values):

    ospf_states = {
        1   :   'DOWN',
        2   :   'ATTEMPT',
        3   :   'INIT',
        4   :   '2WAY',
        5   :   'EXSTART',
        6   :   'EXCHANGE',
        7   :   'LOADING',
        8   :   'FULL'
    }

    # Save all gathered data to dictionary
    ospf_neighbor_data = {}

    ### DEBUG OUTPUT

    if snmp_check_values['debug']:
        print('\n // DEBUG snmp_check_values\n')
        for key,value in sorted(snmp_check_values.items()):
            print(' {key:20} {value}'.format(**locals()))
        print('\n // DEBUG ospf_states\n')
        for key, value in sorted(ospf_states.items()):
            print(' {key}: {value}'.format(**locals()))

    ### GET DATA

    ## Run snmpwalk commands

    try:

        # snmpwalk: get OSPF neighbor interface IP's (read: next-hops)
        command_output_ospf_ip = subprocess.check_output(
            [
                'snmpwalk', '-v', '2c', '-c',
                snmp_check_values['community'],
                snmp_check_values['host'],
                snmp_check_values['ospfNbrIpAddr']
            ]
        )

        # snmpwalk: get OSPF neighbor router  ID's
        command_output_ospf_rid = subprocess.check_output(
            [
                'snmpwalk', '-v', '2c', '-c',
                snmp_check_values['community'],
                snmp_check_values['host'],
                snmp_check_values['ospfNbrRtrId']
            ]
        )

        # snmpwalk: get OSPF neighbor states
        command_output_ospf_state = subprocess.check_output(
            [
                'snmpwalk', '-v', '2c', '-c',
                snmp_check_values['community'],
                snmp_check_values['host'],
                snmp_check_values['ospfNbrState']
            ]
        )

    except:

        msg = 'Something went wrong with subprocess command \'snmpwalk\''
        msg += '\nIs the host ' + snmp_check_values['host'] + ' reachable?'
        msg += '\nIs it configured to accept SNMP polls from this host?'
        msg += '\nIs SNMP community string \'' + snmp_check_values['community'] + '\' valid?'

        error(msg)

    ## Parse snmpwalk commands

    try:

        # Parse command output: OSPF neighbor router interface IP's
        command_output_ospf_ip_list = command_output_ospf_ip.decode().split('\n')
        # Parse command output: OSPF router ID's
        command_output_ospf_rid_list = command_output_ospf_rid.decode().split('\n')
        # Parse command output: OSPF router states
        command_output_ospf_state_list = command_output_ospf_state.decode().split('\n')

        ## Validate SNMP output

        # If you try to use this plugin on a non-Cisco IOS router, this happens
        for item in command_output_ospf_ip_list:
            if 'No Such Object available on this agent at this OID' in item:
                msg = 'SNMP OID not found: 1.3.6.1.2.1.14.10.1.1 (ospfNbrIpAddr). \
                \nAre you sure this is a Cisco IOS router?'
                error(msg)

        ## Parse lists to dictionary

        ## Parse router IP's

        count = 1
        for item in command_output_ospf_ip_list:
            # Start building dictionary
            neighbor_name = 'Neighbor' + str(count).zfill(2)
            count += 1
            chunks = item.split()
            if len(chunks) == 4:
                ip_address = chunks[3]
                # Create a dictionary with key: 'NeighborXX' and value: list of wanted info
                ospf_neighbor_data[neighbor_name] = ['Neighbor IP', ip_address]

        ## Parse router ID's

        for item in command_output_ospf_rid_list:
            # Find matching key/value pair in dictionary, so we can add the RID's we find to the correct pair
            chunks = item.split()
            if len(chunks) == 4:
                # Search for RID in ospfNbrRtrId
                for key, value in ospf_neighbor_data.items():
                    result = re.search(value[1], chunks[0])
                    if result:
                        ospf_neighbor_data[key].append('RID')
                        ospf_neighbor_data[key].append(chunks[3])

        ## Parse OSPF neighbor states

        for item in command_output_ospf_state_list:
            chunks = item.split()
            if len(chunks) == 4:
                for key, value in ospf_neighbor_data.items():
                    result = re.search(value[1], chunks[0])
                    if result:
                        ospf_neighbor_data[key].append('State')
                        ospf_neighbor_data[key].append(chunks[3])

        ### DEBUG OUTPUT

        if snmp_check_values['debug']:

            print('\n // DEBUG ospf_neighbor_data\n')
            print(' {:15}  {}'.format('Name', 'Data'))
            print(' {:15}  {}'.format('-----', '-------------------------'))
            for key, value in sorted(ospf_neighbor_data.items()):
                print(' {key:15} {value}'.format(**locals()))
            print()


        ### EVALUATE DATA USING USER INPUT

        msg_ospf_state_warning = ''
        ospf_neighbors_down = 0
        ospf_neighbors_up = 0
        ospf_neighbors_evaluated = 0
        ospf_neighbors_total = len(ospf_neighbor_data.keys())

        # Check if specified IP's/RID's are actually found
        neighbors_found_set = set()
        neighbors_to_check_set = set()

        for key, value in ospf_neighbor_data.items():

            current_ip = value[1]
            current_rid = value[3]
            ospf_status = int(value[5])

            ## IP: Check for specified IP(s)

            if snmp_check_values['ip']:
                for item in snmp_check_values['ip']:
                    # Build a set of specified values
                    neighbors_to_check_set.add(item)
                    # item is one of the IP's from user input
                    if item == current_ip:
                        # Build a set of matched values
                        neighbors_found_set.add(item)
                        # If not 2WAY or FULL create warning message
                        if not ospf_status == 4 and not ospf_status == 8:
                            # If encountered before, add separator // to string
                            if msg_ospf_state_warning:
                                msg_ospf_state_warning += ' // '

                            warning_msg = 'OSPF neigbor IP ' + current_ip + ' and RID ' + current_rid + ' has state ' + \
                                          ospf_states[ospf_status]

                            msg_ospf_state_warning += warning_msg
                            ospf_neighbors_down += 1
                            ospf_neighbors_evaluated += 1

                        # .. else, if 2WAY or FULL neighbor detected, just count it
                        if ospf_status == 4 or ospf_status == 8:
                            ospf_neighbors_up += 1
                            ospf_neighbors_evaluated += 1

            ## RID: Check for specified RID(s)

            elif snmp_check_values['rid']:
                for item in snmp_check_values['rid']:
                    neighbors_to_check_set.add(item)
                    # item is one of the IP's from user input
                    if item == current_rid:
                        neighbors_found_set.add(item)
                        # If not 2WAY or FULL create warning message
                        if not ospf_status == 4 and not ospf_status == 8:
                            # If encountered before, add separator // to string
                            if msg_ospf_state_warning:
                                msg_ospf_state_warning += ' // '

                            warning_msg = 'OSPF neigbor IP ' + current_ip + ' and RID ' + current_rid + ' has state ' + \
                                          ospf_states[ospf_status]

                            msg_ospf_state_warning += warning_msg
                            ospf_neighbors_down += 1
                            ospf_neighbors_evaluated += 1

                        # .. else, if 2WAY or FULL neighbor detected, just count it
                        if ospf_status == 4 or ospf_status == 8:
                            ospf_neighbors_up += 1
                            ospf_neighbors_evaluated += 1

            else:
                # If not 2WAY or FULL create warning message
                if not ospf_status == 4 and not ospf_status == 8:
                    # If encountered before, add separator // to string
                    if msg_ospf_state_warning:
                        msg_ospf_state_warning += ' // '

                    warning_msg = 'OSPF neigbor IP ' + current_ip + ' and RID ' + current_rid + ' has state ' + \
                                  ospf_states[ospf_status]

                    msg_ospf_state_warning += warning_msg
                    ospf_neighbors_down += 1
                    ospf_neighbors_evaluated += 1

                # .. else, if 2WAY or FULL neighbor detected, just count it
                if ospf_status == 4 or ospf_status == 8:
                    ospf_neighbors_up += 1
                    ospf_neighbors_evaluated += 1

        ### EVALUATE RESULTS AND GENERATE OUTPUT

        # Spelling is important
        extra_s = ''
        if ospf_neighbors_up > 1:
            extra_s = 's'

        # Totals
        msg_totals = ' (' + str(ospf_neighbors_up) + ' neighbor' + extra_s + ' up out of ' + str(ospf_neighbors_evaluated) + \
                     ' checked, ' + str(ospf_neighbors_total) + ' detected)'

        # Perf data
        msg_perfdata = ' | ospf_neighbors=' + str(ospf_neighbors_up)

        # WARNING: Warnings detected
        if msg_ospf_state_warning:
            warning(msg_ospf_state_warning + msg_totals + msg_perfdata)

        # CRITICAL: Not all neighbours found
        if snmp_check_values['min_neighbors'] > ospf_neighbors_up:
            msg = str(ospf_neighbors_up) + ' OSPF neighbor' + extra_s + ' detected (Required: ' + \
                  str(snmp_check_values['min_neighbors']) + ')'
            critical(msg + msg_totals + msg_perfdata)

        # CRITICAL: Specified neighbor not found
        neighbors_not_found_set = neighbors_to_check_set.difference(neighbors_found_set)

        if not len(neighbors_not_found_set) == 0:
            msg = 'Could not find:'
            for item in neighbors_not_found_set:
                msg += ' ' + item
            critical(msg + msg_totals + msg_perfdata)

        # OK
        msg = str(ospf_neighbors_up) + ' OSPF neighbor' + extra_s + ' in state 2WAY or FULL'
        ok(msg + msg_totals + msg_perfdata)

    # Catch own sys.exit in case it was called and exit gracefully
    except SystemExit:
        raise

    # On all other exceptions quit with an error
    except:
        msg = 'Something went wrong parsing data. Probably wrong SNMP OID for this device.'
        error(msg)


def main():

    # Parse command line arguments
    parser = argparse.ArgumentParser(
        description='Icinga (Nagios) plugin that checks the status of OSPF neighbors on a Cisco IOS router.\
        The check returns OK if the neighbor state is 2WAY or FULL.\
        Without any optional arguments, returns OK if any OSPF neighbors are detected.\
        Optional arguments can be passed to match a specific neighbor Router ID (RID) or interface IP to look for.\
        In that case a CRITICAL will be generated if that specific neighbor is down.\
        In case multiple IP\'s or RID\'s are provided, a WARNING is generated if any of them is not 2WAY or FULL.\
        If you set both IP\'s and RID\'s, only the IP\'s will be checked.',
        epilog='Written in Python 3.'
    )
    parser.add_argument('--version', action='version', version=__version__)
    parser.add_argument('--debug', action='store_true', help='debug output')
    parser.add_argument('SNMP_COMMUNITY', type=str, help='the SNMP community string of the remote device')
    parser.add_argument('HOST', type=str, help='the IP of the remote host you want to check')
    parser.add_argument('-r', '--rid', type=str, help='OSPF neighbor router ID (multiple possible separated by a comma \
    and in-between quotes)')
    parser.add_argument('-i', '--ip', type=str, help='OSPF neighbor IP (multiple possible separated by a comma \
    and in-between quotes)')
    parser.add_argument('-n', '--number', type=int, help='Minimum number of OSPF neighbors required (overrides --ip )')
    args = parser.parse_args()

    # Default values
    snmp_check_values = {
        'community'                 : args.SNMP_COMMUNITY,
        'host'                      : args.HOST,
        'ospfNbrIpAddr'             : '1.3.6.1.2.1.14.10.1.1',
        'ospfNbrRtrId'              : '1.3.6.1.2.1.14.10.1.3',
        'ospfNbrState'              : '1.3.6.1.2.1.14.10.1.6',
        'rid'                       : None,
        'ip'                        : None,
        'min_neighbors'             : None,
        'debug'                     : False
    }

    # Debug mode enabled?
    if args.debug:
        snmp_check_values['debug'] = True

    # RID set?
    if args.rid:
        rid_list = [args.rid]
        # Check if multiple RID's are given
        if ',' in args.rid:
            rid_list = []
            # Separate IP's by comma
            rid_list_raw = args.rid.split(',')
            # Strip whitespace from IP's
            for item in rid_list_raw:
                # Make sure results are strings (not lists) by using index 0
                rid_list.append(item.split()[0])

        snmp_check_values['rid'] = rid_list


    # Neighbor IP set?
    if args.ip:
        ip_address_list = [args.ip]
        # Check if multiple IP's are given
        if ',' in args.ip:
            ip_address_list = []
            # Separate IP's by comma
            ip_address_list_raw = args.ip.split(',')
            # Strip whitespace from IP's
            for item in ip_address_list_raw:
                # Make sure results are strings (not lists) by using index 0
                ip_address_list.append(item.split()[0])

        snmp_check_values['ip'] = ip_address_list


    # Minimum amount of OSPF neighbors set?
    if args.number:
        snmp_check_values['min_neighbors'] = args.number
    else:
        snmp_check_values['min_neighbors'] = 0


    # Check OSPF status
    check_ospf(snmp_check_values)


if __name__ == '__main__':
    main()



# Copyright (c) 2014, raoul@node00.nl
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.