#!/usr/bin/perl -w
########################################################################################################################
#
# 					     Cisco IPS Health Management 
#
# DESCRIPTION: 		Nagios check that uses expect to SSH into a Cisco IPS device 
#			and returns key data and checks for various conditions. Provides 
#			a check to ensure the correct users are set up on the device. 
#			It can also be utilized as an event handler to reset the sensor
# 			if the AnalysisEngine is found to be in a 'NotRunning' state.
#
########################################################################################################################
#
# AUTHOR: 		Dave Schwartzburg (daschwar at cisco.com)
#			Copyright 2009 Dave Schwartzburg
#
# LICENSE:		GPL - http://www.fsf.org/licenses/gpl.txt
#
#			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.
#
#			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.
#
########################################################################################################################
#
# SECURITY WARNING: 	If you provide a password as an argument to this check it will be visible to any 
#			user that has access to view running processes of the Nagios user or that has 
#			access to your Nagios configuration files.
#	
#			It is possible to add SSH version 1 authorized keys to the sensor using the 
#			'ssh authorized-key' command in config mode. This can be used to eliminate the 
#			need of using a password to access the sensor.
#
#                       The service account functionality on the Cisco IPS is not officially supported
#                       It is intended only for Cisco Technical Assistance Center (TAC) support. However, 
#                       it can be used in order to add a SSH version 2 public key to the authorized_keys file 
#			of a particular user. Please see the following for more information about the service account:
#
#                       http://www.cisco.com/en/US/docs/security/ips/6.0/configuration/guide/cli/cliTasks.html#wp1073485 
#
########################################################################################################################
#
# USAGE INFORMATION:	There are three main methods of operation:
#				-c	perform the standard health check
#				-e	perform a check against the downloaded users file output 
#					(from -c/--check used with -U/--user-data-dir)
#				-r	used as an event handler to reset the sensor
#
#			Scroll down or run the check with the -h option for usage
#
########################################################################################################################
# 
# HISTORY:
#   			2009-05-05: Initial release 
#
########################################################################################################################

use strict;
use Date::Manip;
use Expect;
use Getopt::Long;
use IO::Capture::Stdout;
use POSIX;
use Term::ReadKey;

# Variable declarations and initialization
my ($capture, $check, $command, $crit_users, $cur_ser_pack, $cur_sig_pack, $debug, $domain, $examine, $except_ser_pack, $except_sig_pack, $err, $exit, $exp, $help, $host, $hostname, $identity_file, 
    $interval, $license_threshold, @lines, $log, $match_pp, @options, @patterns, $password, $reset, $results, @ret, $return, $timeout, $username, $user_data, $versions);
$crit_users = '';
$domain = '';
$err = 0;
$except_ser_pack = 'none';
$except_sig_pack = 'none';
$exit = 0;
$exp = new Expect;
$interval = 3600;
$license_threshold = 7;
$timeout = 10;
$user_data = '';

# Parse command line options
Getopt::Long::Configure('bundling');
GetOptions (
	"C=s" => \$crit_users, "critical_users=s"		=> \$crit_users,
	"c"   => \$check, "check"    				=> \$check,
	"D=s" => \$domain, "domain=s"  				=> \$domain,
	"d"   => \$debug, "debug"    				=> \$debug,
	"e"   => \$examine, "examine-users"   			=> \$examine,
	"H=s" => \$hostname, "hostname=s" 			=> \$hostname,
	"h"   => \$help, "help"       				=> \$help,
	"I=i" => \$interval, "interval=i" 			=> \$interval,
	"i=s" => \$identity_file, "identity-file=s" 		=> \$identity_file,
	"L=i" => \$license_threshold, "license-threshold=i"	=> \$license_threshold,
	"l=s" => \$log, "log=s"					=> \$log,
	"o=s" => \@options, "options=s"				=> \@options,
	"p=s" => \$password, "password=s"  			=> \$password,
	"r=s"   => \$reset, "reset=s"				=> \$reset,  
	"t=i" => \$timeout, "timeout=i"  			=> \$timeout,
	"U=s" => \$user_data, "user-data-dir=s" 		=> \$user_data,
	"u=s" => \$username, "username=s" 			=> \$username,
	"v=s" => \$versions, "versions=s"			=> \$versions
	);

# Print usage info if requested or insufficient/incorrect arguments are provided
if ((($check || $examine || $reset) && !$hostname) || !($check || $examine || $reset) || ($check && ($examine || $reset)) || ($examine && $reset) || ($check && !$username) || $help) {
	print 
       "Usage: cisco_ips.pl -c -e -r state,output -H host 
					[-C criticalusers] [-D] [-h] [-d domain] [-I interval] [-i private-key] 
				       	[-L threshold] [-l log] [-o sshoption] [-p password] [-t timeout] 
				       	[-U userdatafile] [-u username] [-v versionfile]

	FUNCTIONS:
	-c, --check
	    Perform the Cisco IPS health check
	-e, --examine-users
	    Examine the data stored in the file that is output from -c -U/--user-data-dir file
	-r, --reset=SERVICESTATE,OUTPUT
	    Attempt to connect to the sensor and reset it. Intended for when Analysis Engine is in status 'NotRunning'
	    (Reset the sensor if SERVICESTATE is critical and OUTPUT contains NotRunning)

	OPTIONS:
	-C, --critical-users=USERS
	    A comma separated list of users expected to be found on the IPS device (-e check only)
	-D, --domain=DOMAIN
	    Domain to append to hostname (Example: If -H abc and -D def.org, connection will be made to abc.def.org) 
	-d, --debug
            Print debug information while running the check. Can be used with Nagios 3 to provide multiline output
	-H, --hostname=HOST
	    IP or hostname of the Cisco IPS sensor
	-h, --help
	    Print this help screen
	-I, --interval=MINUTES
            Trigger a warning when the stored user data is older than interval (-e check only)
	-i, --identity-file=SSHPRIVATEKEY
	    Private key to be used to log into the sensor (-c and -r only)
	-L, --license-threshold=DAYS
	    Warn if remaining days of license fall below this threshold. Default: 7 (-c check only)
	-l, --log=LOGFILE
	    Log results of the check to the LOGFILE. Recommended for sensor resets (-c and -r only)
	-o, --options=ARGUMENTS
	    Used to pass SSH configuration file style options. For details see ssh_config(5) (-c and -r only)
	-p, --password=PASSWORD
	    Password to log into the sensor (-c and -r only)
	-t, --timeout=TIMEOUT
	    Timeout for connecting to the host and expect commands in seconds. Default: 10 (-c and -r only)
	-U, --user-data-dir=USERDIR
	    Directory to log users data file to, to verify certain users are configured on the sensor (-c and -e only)
	-u, --username=USER
	    Username to log into the sensor (-c and -r only)
	-v, --versions=VERSIONFILE
	    File that contains service and signature pack versions to compare against (-c only)
	      FORMAT:
		all,service pack version,sig pack version (the versions all sensors should be at)
		hostname,service pack version,sig pack version (individual exceptions)

	SECURITY WARNING: 	If you provide a password as an argument to this check it will be visible to any 
				user that has access to view running processes of the Nagios user or that has 
				access to your Nagios configuration files.\n\n";
	exit 3;
}

# If you ever run this check from a screen session this will keep it from causing unusual behavior
$ENV{'TERM'} = 'linux';

# Figure out FQDN and hostname based on domain and hostname input
$host = $hostname;
if ($hostname !~ /\./ && $domain) {
	$host .= "." . $domain;
} elsif ($hostname =~ /\./ && $domain) {
	$hostname = ($host =~ /(.*?)\./)[0];
} elsif ($hostname =~ /\./ && !$domain) {
	($hostname, $domain) = ($host =~ /(.*?)\.(.*)/)[0,1];
}
$debug = "Host: $host, hostname: $hostname, Domain: $domain\n" if $debug;

# Only reset the sensor if the AnalysisEngine is in the 'NotRunning' state and the service state is critical
if ($reset && !($reset =~ /CRITICAL/i && $reset =~ /NotRunning/i)) {
	logger("Not resetting $host. Event handler fired with check output: $reset") if $log;
	exit 0;
}

# Examine the user file for the specified host if -e option is used
if ($examine) {
	my @data;
	my @missing_users;
	my $time = 0;
	my @users;
	
	# Handle trailing slash and ensure a valid direction was provided
	$user_data =~ s/(.*)\//$1/ if $user_data =~ /\/$/;
	$debug .= "User data: $user_data\n" if $debug;
	unless (-e $user_data && -d $user_data) {
		print "You must provide a valid directory that stores the user data for the sensor $host\n";
		exit 2;
	}
	# Make sure a data file exists for the sensor specificed in $host
	unless (-f "$user_data/$host") {
		print "Unable to find the user data file for the sensor $host\n";
		exit 2;
	}

	# Open the file and read in the data
	open (USRFILE, "$user_data/$host") || ($err = $!);
	if ($err) {
		print "Unable to open users file for $host";
		if ($debug) {
			$debug .= "Error: Unable to open $user_data/$host: $err\n";
			print "|\n$debug";
		}
		exit 2;
	}
	$err = 0;
	while (<USRFILE>) {
		chomp($_);
		push(@data, $_);
	}
	close (USRFILE);
	
	# Parse users found on the sensor
	my $index = 2;
	while ($index++ < ($#data - 1)) {
		push (@users, $1) if $data[$index] =~ /(?:\*\s*\d{1,5})?\s*(\S*)/;
	}

	# Compare users found versus those expected
	my @crit_users = split(/,/, $crit_users);
	foreach my $user (@crit_users) {
		push (@missing_users, $user) unless grep {/^$user$/} @users;
	}

	# Print results
	print $#crit_users + 1, " expected users found" if $#crit_users >= 0;
	print ", " if $#crit_users >= 0 && $#users > $#crit_users;
	print $#users + 1, " total users" if $#users > $#crit_users;
	if ($#missing_users >= 0) {
		print "  ** Missing users: ", (map {qq|$_ |} @missing_users), "**";
		$exit = 2;
	}
	
	# Check to make sure the data is at least as fresh as our interval (default 1 hour)
	$time = ($data[0] =~ /(\d{10,})/)[0];
	if (time - $time > ($interval * 60)) {
		print "  ** User data is older than $interval minutes **";
		$exit = 1;
	} 
	print "\n";
	exit $exit;
}

# Read password if it was not provided and not using public key auth. Useful for testing/debugging
unless ($password || $identity_file) {
	ReadMode('noecho');
	print "Enter the password for the Cisco IPS device $host: ";
	my $pw = ReadLine(0);
	chomp $pw;
	ReadMode('normal');
	print "\n";
	$password = $pw;
}

# Construct the SSH command and establish the SSH session to sensor using public key or password auth
$command = qq|ssh -oStrictHostKeyChecking=no -c blowfish "$username\@$host"|;
#$command = qq|ssh -oStrictHostKeyChecking=no -oConnectTimeout=| . ($timeout - 1) .qq| -c blowfish "$username\@$host"|;
$command =~ s/ssh/ssh -i $identity_file/ if $identity_file;

# Verify key exists and is readable
if ($identity_file) {
	if (!-f $identity_file) {
		print "The SSH key specified does not exist: $identity_file\n";
		exit 1;
	}
	if (!-r $identity_file) {
		print "The SSH key specified is not readable: $identity_file\n";
		exit 1;
	}
}

# Allow for use of multiple SSH options through the -o argument
if ($#options >= 0) {
	foreach my $arg (@options) {
		$command =~ s/ssh/ssh -o$arg/;
	}
}

# Establish SSH connection
$debug .= "Establishing connection to $host with command: $command\n" if $debug;
$exp->spawn($command) || ($err = "Cannot spawn $command: $!");

# Handle errors from spawn
if ($err) {
	print "Failed to spawn SSH to $host, error: $err\n";
	exit 2;
}
$err = 0;

# Capture stdout from expect
$capture = IO::Capture::Stdout->new();
$capture->start();

# Look for the password prompt and submit the password
if ($password && !$identity_file) {
	$exp->expect($timeout, "Password:");
	$exp->send(qq|$password\r|);
}

# The IPS Should prompt for a password if auth failed, otherwise it should be successfully logged into the host
@patterns = ("Password:", "$hostname#");
@ret = $exp->expect($timeout, @patterns);
$err = $ret[1];
$match_pp = $ret[0];

# Handle potential errors
if ($err) {
	$capture->stop();
	my $lines = join(" ", $capture->read);
	print "SSH timed out connecting to $host";
	if ($debug) {
		$debug .= "Error: $err\n";
		print "|\n$debug";
	}
	logger("SSH timed out connecting to $host\n") if $log;
	# A regular 'exit 2' does not work because expect times out and SIGHUPs the SSH process spawned by expect
	POSIX:_exit(2);
}
if ($match_pp == 1) {
	my $mesg;
	$capture->stop();
	$mesg = "SSH key is not properly deployed on $host\n" if $identity_file && !$password;
	$mesg = "Password authentication on $host failed\n" if $password && !$identity_file;
	$mesg = "Auth failed to $host, why are you using both password and public key authentication?\n" if $password && $identity_file;
	print "$mesg\n";
	logger("$mesg\n") if $log;
	exit 2;
} elsif ($match_pp != 2) {
	$capture->stop();
	print "Unknown error occurred while attempting to log into $host\n";
	logger("Unknown error occurred while attempting to log into $host\n") if $log;
	exit 2;
}
$err = 0;
$debug .= "SSH connection established, capturing stdout from expect\n" if $debug;


# Run desired commands on sensor to either check the current status or reset the sensor
$debug .= "Running commands on the sensor via expect\n" if $debug;
exp_cmd("term length 0", "$hostname# ", "Failed to set term length");
if ($reset) {
	exp_cmd("reset", ".*Continue with reset?.*", "Failed to issue reset command", 1);
	exp_cmd("yes", "(?:A system reboot has been requested.  The reboot may not start for 90 seconds|Request Succeeded|Sensor is shutting down.This CLI session will be terminated)", "Reset issued but not successful", 1);
} else {
	exp_cmd("sh ver", "$hostname# ", "Failed to execute sh ver");
	exp_cmd("sh int brief", "$hostname# ", "Failed to execute sh int brief");
	exp_cmd("sh int", "$hostname# ", "Failed to execute sh int");
	exp_cmd("sh users all", "$hostname# ", "Failed to execute sh users all") if $user_data;
}

# Close out connection, stop stdin capture, format results (clean up control characters)
$exp->send("exit\r");
$exp->soft_close();
$capture->stop();
@lines = $capture->read;
$return = join("", @lines);
$return =~ s/\[C//g;
$return =~ tr/\012,\040-\176/ /c;

# Check if the reset attempt was successful or not.
if ($reset) {
	if ($return =~ /(?:A system reboot has been requested.  The reboot may not start for 90 seconds|Request Succeeded|Sensor is shutting down.This CLI session will be terminated)/i) {
                $reset = "Successfully reset the sensor: $host\n";
        } else {
                $reset = "Failed to reset the sensor: $host\n";
        }	
}	

# Log results if needed
$reset ? logger("$return\n$reset") : logger("$return") if $log;

# Exit here if reset function was called
exit 0 if ($reset);

# Parse IPS data from expect results
my $ips_ver = $1 if $return =~ /Cisco Intrusion Prevention System, Version\s*(\S*)/;
my $license = $1 if $return =~ /(?:Trial license|Licensed?),? expire(?:s|d):\s*(\S*)/;
my $mpp;
my $platform = $1 if $return =~ /Platform:\s*(\S*)/;
my $serial = $1 if $return =~ /Serial Number:\s*(\S*)/; 
my $sig_pack = $1 if $return =~ /Signature Update\s+(S\S*)/;
my $uptime = $1 if $return =~ /Sensor up-time is (.*)\./;
(my $mainapp_ver = $1, my $mainapp_status = $2) if $return =~ /MainApp\s*(\S*)\s*\(\S*\)\s*\S*\s*(\S*)/;
(my $sensorapp_ver = $1, my $sensorapp_status = $2) if $return =~ /AnalysisEngine\s*(\S*)\s*\(\S*\)\s*\S*\s*(\S*)/;
my $user = $1 if $return =~ /sh users all(.*?)$hostname/s && $user_data;

# Write user data out for separate service check
if ($user_data) {
	$user_data =~ s/(.*)\//$1/ if $user_data =~ /\/$/;
	$debug .= "Attempting to write user data to file: $user_data/$host\n" if $debug;
	open (USER, ">$user_data/$host") || ($err = $!);
	if (!$err) {
		print USER time . "\n";
		print USER $user;
		close (USER);
	} else {
		$debug .= "Failed to open the file $user_data/$host. Error: $err\n" if $debug;
	}
	$err = 0;
}

# Parse 'sh int' data to find sensing interfaces, then look for their Missed Packet Percentages
my $curr_int = 'global';
my %mpps;
my $sensing_ints;
my @sensing_ints;
my $state = 0;
my $sum = 0;

# Review show interface data for missed packet percentage information for each enabled sensing interface
foreach my $line (split(/\n/, $return)) {
	$state = 1 if $line =~ /sh int brief/ && $state == 0;
	if ($state == 1) {
		if ($line =~ /\*?\s+(\S+)\s+(\S+)\s+(\S+)/) {
			(my $int = $1, my $sensing = $2, my $link = $3);
			if ($sensing =~ /enabled/i && $link =~ /up/i) {
				push (@sensing_ints, $int);
				$debug .= "Adding interface: $int, found in line: $line\n" if $debug;
			}
		}
		if ($line =~ /sh int(?! brief)/) {
			$state = 2;
			$sensing_ints = "(" . quotemeta(join("\|", @sensing_ints)) . ")";
			$debug .= "Sensing interfaces found: $sensing_ints\n" if $debug;
		}
	}
	if ($state == 2) {
		$curr_int = $1 if $line =~ /MAC statistics from interface (\S+)/;
		if (grep(/$curr_int/, @sensing_ints) && $line =~ /Missed Packet Percentage = (\d+)/i) {
			$mpps{$curr_int} = $1;
			$debug .= "Current interface is sensing: $curr_int, Found MPP of: $mpps{$curr_int} in line: $line\n" if $debug;
		}
	}
}	
my $mpps = scalar(keys(%mpps));
foreach (keys %mpps) {
	$sum += $mpps{$_} if $_ !~ /global/;
}
$debug .= "Sensing ints: @sensing_ints, sum: $sum, mpps: $mpps, sensing ints_count: " . ($#sensing_ints + 1) . "\n" if $debug;

# Calculate the average missed packet percentage for all sensing interfaces
$sum == 0 ? ($mpp = 0) : ($mpp = $sum / $mpps);
$mpp = '**No sensing ints enabled**' if $mpps < 1 || $mpps != ($#sensing_ints + 1);

# Calculate how many days until license expires
my $license_date = ParseDate($license);
my $curr_date = ParseDate("midnight today");
my $delta = DateCalc($curr_date,$license_date,\$err);
my $diff = Delta_Format($delta, 1, "%dt");
$diff =~ s/\.0//;
$err = 0;
$debug .= "License: $license, Diff: $diff, License Threshold: $license_threshold\n" if $debug;

# Format results
$results =  "Ver: $ips_ver Sigs: $sig_pack Platform: $platform SN: $serial License: $license ($diff) Uptime: $uptime MPP: $mpp ";

# Print consolidated MainApp & AnalysisEngine status/version if similar
if ($mainapp_ver eq $sensorapp_ver && $mainapp_status eq $sensorapp_status) {
	$results .= "MainApp/AnalysisEngine: $mainapp_ver, status: $mainapp_status";
} elsif ($mainapp_ver eq $sensorapp_ver) {
	$results .= "MainApp/AnalysisEngine: $mainapp_ver, MainApp status: $mainapp_status, AnalysisEngine: $sensorapp_status";
} elsif ($mainapp_status eq $sensorapp_status) {
	$results .= "MainApp: $mainapp_ver, AnalysisEngine: $sensorapp_ver status(both): $mainapp_status";
} else {
	$results .= "MainApp: $mainapp_ver, status: $mainapp_status ";
	$results .= "AnalysisEngine: $sensorapp_ver, status: $sensorapp_status";
}

# Read what the current service and sig pack values should be and perform check to verify if the service and signature packs are at the expected revision
if ($versions) {
	open (VERSIONS, "$versions") || (my $err = $!);
	if (!$err) {
		my @data = <VERSIONS>;
		close (VERSIONS);
		foreach my $line (@data) {
			$line =~ s/\n//;
			my @parts = split(/,/, $line);
			if ($parts[0] =~ /all/i) {
				$cur_ser_pack = quotemeta($parts[1]) || '';
				$cur_sig_pack = $parts[2] || '';
				$debug .= "Service Pack: $cur_ser_pack, Signature Pack: $cur_sig_pack\n" if $debug;
			} elsif ($parts[0] =~ /($hostname|$host)/i) {
				$except_ser_pack = quotemeta($parts[1]) || 'none';
				$except_sig_pack = $parts[2] || 'none';
				$debug .= "Exception found for $1: Service Pack: $except_ser_pack, Signature Pack: $except_sig_pack\n" if $debug;
			}
		} 
		if ($cur_ser_pack ne '' && $cur_sig_pack ne '') {
			if ($ips_ver !~ /$cur_ser_pack|$except_ser_pack/ && $sig_pack !~ /$cur_sig_pack|$except_sig_pack/) {
				$results .= " **Sensor running different service pack and sig pack**";
				$exit = 1;
			} elsif ($ips_ver !~ /$cur_ser_pack|$except_ser_pack/) {
				$results .= " **Sensor running different service pack**";
				$exit = 1;
			} elsif ($sig_pack !~ /$cur_sig_pack|$except_sig_pack/) {
				$results .= " **Sensor running different sig pack**";
				$exit = 1;
			}
		} else {
			$debug .= "Sig/Service-pack version file must contain line: all,service pack version,sig pack version\n" if $debug;
		}
	} else {
		$debug .= "Failed to open the sig/service-pack versions file $versions. Error: $err\n" if $debug;
	}
	$err = 0;
}

## Print any applicable warning messages, set appropriate exit code
# Exit with a warning if the license is expiring soon
if ($diff <= $license_threshold and $diff > 0) {
	my $days = 'days';
	$days = 'day' if $diff == 1;
	$results .= " **License Expires in $diff $days **";
	$exit = 1;
}
# If no enabled, link-up sensing interfaces are found then trigger a warning state
if ($mpp =~ /No sensing ints enabled/) {
	$exit = 1;
}
# If the license has expired then trigger a critical state
if ($return =~ /The license key.*has expired/) {
	$results .= " **No Valid License**";
	$exit = 2;
}
# If the analysisEngine is not running trigger a critical state
if ($sensorapp_status eq 'NotRunning') {
	$exit = 2;
}

print "$results";
print "|\n$debug" if $debug;
exit $exit;


## SUBS
# Run commands via the expect object, accept returns, check for errors
sub exp_cmd {
	my $command = shift;
	my $exp_ret = shift;
	my $err_msg = shift;
	my $re = shift;
	
	$exp->send("$command\r");
	print "Trying to do a regular expression match!\n" if $re;
	$re ? (@ret = $exp->expect($timeout, '-re', "$exp_ret")) : (@ret = $exp->expect($timeout, "$exp_ret"));

	if ($ret[0] != 1) {
		$capture->stop();
		print "$err_msg\n";
		exit 2;
	}
}

# Log to the specified file
sub logger {
	my $error = 0;
	my $log_data = shift;
	my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime(time);
	$debug .= "Attempting to log to the file: $log\n" if $debug;
	open (LOG, ">>$log") || ($error = $!);
	if (!$error) {
		printf (LOG "%4d-%02d-%02d %02d:%02d:%02d: ", ($year + 1900), ($mon + 1), $mday, $hour, $min, $sec);
		print LOG $log_data . "\n";
		close (LOG);
	} else {
		$debug .= "Failed to open the log file $log. Error: $error\n" if $debug;
	}
}
