#!/usr/bin/perl
#
# Check ipf stats
#
# $Id: check_ipf,v 1.20 2017/06/14 21:21:32 doke Exp $


use strict;
#use warnings;
use Getopt::Long;
#use Data::Dumper;

use vars qw( $ipf $ipfstat $sudo $verbose $help @crits @warns @unknowns @oks
    @ignores %stats @perf_stats $key );


$ipf = "/usr/sbin/ipf";
$ipfstat = "/usr/sbin/ipfstat";
$sudo = "/opt/sfw/bin/sudo";
$verbose = 0;
$help = 0;


# Which performance statistics we want to send to nagios.
# Most important ones first, in case our output gets truncated.
@perf_stats = ( 
    'pct_state_use', 
    'avg_len',    # average current chain length
    'max_len',    # longest current chain length
    'pct_bkt_use', 
    'active', 
    'bkt_use', 
    'ICMP', 
    'TCP', 
    'UDP', 
    'closed', 
    'expired', 
    'hits', 
    'misses', 
    'max_bkt',    # max bucket error
    'no_memory', 
    'nrules', 
    'nrules_hit', 

    #'fr_active', 
    #'fr_chksrc', 
    #'fr_control_forwarding', 
    #'fr_defnatage', 
    #'fr_defnaticmpage', 
    #'fr_flags', 
    #'fr_icmpacktimeout', 
    #'fr_icmptimeout', 
    #'fr_ipfrttl', 
    #'fr_nat_lock', 
    #'fr_nat_maxbucket', 
    #'fr_nat_maxbucket_reset', 
    #'fr_pass', 
    #'fr_state_lock', 
    'fr_state_maxbucket', 
    #'fr_state_maxbucket_reset', 
    'fr_statemax', 
    'fr_statesize', 
    #'fr_tcpclosed', 
    #'fr_tcpclosewait', 
    #'fr_tcphalfclosed', 
    #'fr_tcpidletimeout', 
    #'fr_tcplastack', 
    #'fr_tcptimeout', 
    #'fr_udpacktimeout', 
    #'fr_udptimeout', 
    #'fr_unreach', 
    #'fr_update_ipid', 
    #'ipf_hostmap_sz', 
    #'ipf_natrules_sz', 
    #'ipf_nattable_sz', 
    #'ipf_rdrrules_sz', 
    #'ipfr_size', 
    #'ipl_buffer_sz', 
    #'ipl_logall', 
    #'ipl_logmax', 
    #'ipl_suppress', 
    #'ipstate_logging', 
    #'nat_logging', 

    'max', 
    'min_len', 
    );


sub usage {
    my( $rc ) = @_;
    print "Usage: $0 [-vh]
    -v    verbose
    -h    help
";
    exit $rc;
    }

Getopt::Long::Configure ("bundling");
GetOptions(
    'v+' => \$verbose,
    'h' => \$help,
    );
&usage( 0 ) if ( $help );

if ( &find_sudo() ) { 
    &get_ipf();
    &get_ipfstat_s();
    &get_ipfstat_ih();
    &check();
    }

my $rc = 0;
my $sep = '';
if ( $#crits >= 0 ) {
    $rc = 2;
    print "CRITICAL ", join( ", ", @crits );
    $sep = '; ';
    }
if ( $#warns >= 0 ) {
    $rc = 1 if ( $rc == 0 );
    print $sep, "Warning ", join( ", ", @warns );
    $sep = '; ';
    }
if ( $#unknowns >= 0 ) {
    $rc = -1 if ( $rc == 0 );
    print $sep, "Unknown ", join( ", ", @unknowns );
    $sep = '; ';
    }
if ( $rc == 0 ) {
    print "Ok ", join( ", ", @oks );
    $sep = '; ';
    }
if ( $#ignores >= 0 ) {
    print $sep, "Ignoring ", join( ", ", @ignores );
    }

# report stats
print " |";
$sep = '';
foreach $key ( @perf_stats ) {
    if ( defined $stats{ $key } ) { 
	printf "%s %s=%s", $sep, lc $key, $stats{ $key };
	$sep = ',';
	}
    }

print "\n";
exit $rc;


##################


sub find_sudo {
    my( $sudo2 );

    if ( -x $sudo ) {
	# ok
	return 1;
	}

    $sudo2 = "/usr/local/bin/sudo";
    if ( -x $sudo2 ) {
	$sudo = $sudo2;
	return 1;
	}

    $sudo2 = "/opt/sfw/bin/sudo";
    if ( -x $sudo2 ) {
	$sudo = $sudo2;
	return 1;
	}

    $sudo2 = "/usr/ccs/bin/sudo";
    if ( -x $sudo2 ) {
	$sudo = $sudo2;
	return 1;
	}

    push @unknowns, "sudo not found";
    return 0;
    }




# get current settings and tuneables
# 
# The good way to do this is to run "ipf -T list".  

# Older versions of ipf, ie 3.4.31, didn't have that option.
# You can get some of the settings with 
# "echo 'fr_statesize/;fr_statemax/' | mdb -k"
# But running mdb via sudo from inside a nagios plugin scares me.
#
sub get_ipf {
    my( $cmd, $key, $min, $max, $current, $lines );

    if ( ! -x $ipf ) {
	push @unknowns, "$ipf not found";
	return;
	}

    if ( ! -c "/dev/ipf" ) {
	push @unknowns, "no /dev/ipf";
	return;
	}

    $cmd = "$sudo -S $ipf -T list";
    $verbose && print "+ $cmd\n";
    if ( ! open( pH, "$cmd < /dev/null 2>&1 |" ) ) {
	push @unknowns, "can't run $cmd: $!";
	return;
	}
    while( <pH> ) {
	chomp;
	$verbose && print "> $_\n";
	if ( m/^(\w[\w\d_]+) \s+ min \s+ ([\da-fx]+) \s+ max \s+ ([\da-fx]+) \s+ current \s+ (\d.*)/ix ) {
	    $key = $1;
	    $min = $2;
	    $max = $3;
	    $current = $4;

	    $verbose && print "\$stats{ $key } = $current\n";
	    $stats{ $key } = $current;
	    }
	elsif ( m/illegal option/i ) { 
	    # old version of ipf.

	    # could run "echo 'fr_statesize/;fr_statemax/' | mdb -k"
	    # but that makes me nervous

	    # punt and assume defaults
	    # we're unlikely to change it on these old machines
	    $stats{ 'fr_statesize' } = 5737;
	    $stats{ 'fr_statemax' } = 4013;

	    }
	elsif ( m/^Password:|is not in the sudoers file/ ) { 
	    # sudo configuration is wrong
	    push @unknowns, "sudoers is misconfigured";
	    return;
	    }
	elsif ( m/must be setuid root/ ) { 
	    push @unknowns, "sudo is not setuid root";
	    return;
	    }
	# else ignore it
	}
    $lines = $.;
    close pH;
    $verbose && print "lines $lines\n";
    if ( $lines < 1 ) { 
	push @unknowns, "no output from ipf -T list";
	return;
	}
    }




# get current state statistics
sub get_ipfstat_s {
    my( $cmd, $key, $val, $lines );

    if ( ! -x $ipfstat ) {
	push @unknowns, "$ipfstat not found";
	return;
	}

    if ( ! -c "/dev/ipstate" ) {
	push @unknowns, "no /dev/ipstate ";
	return;
	}

    $cmd = "$sudo -S $ipfstat -s";
    $verbose && print "+ $cmd\n";
    if ( ! open( pH, "$cmd < /dev/null 2>&1 |" ) ) {
	push @unknowns, "can't run $cmd: $!";
	return;
	}
    while( <pH> ) {
	chomp;
	$verbose && print "> $_\n";
	if ( m/^\s+ ([\d\.]+) \s* (\S.*)/ix ) {
	    $val = $1;
	    $key = $2;

	    $key =~ s/average/avg/i;
	    $key =~ s/buckets?/bkt/i;
	    $key =~ s/length/len/i;
	    $key =~ s/maximum|maximal/max/i;
	    $key =~ s/minimum|minimal/min/i;
	    $key =~ s/%/pct/i;
	    $key =~ s/usage/use/i;
	    $key =~ s/\s+/_/g;
	    $key =~ s/_$//;

	    $key =~ s/^in_use$/bkt_use/ig;
	    $key =~ s/^bkts_in_use$/bkt_use/ig;

	    $verbose && print "\$stats{ $key } = $val\n";
	    $stats{ $key } = $val;
	    }
	elsif ( m/^Password:|is not in the sudoers file/ ) { 
	    # sudo configuration is wrong
	    push @unknowns, "sudoers is misconfigured";
	    return;
	    }
	elsif ( m/sudo: must be setuid root/ ) { 
	    push @unknowns, "sudo is not setuid root";
	    return;
	    }
	# else ignore it
	}
    $lines = $.;
    close pH;
    $verbose && print "lines $lines\n";
    if ( $lines < 1 ) { 
	push @unknowns, "no output from ipfstat -s";
	return;
	}
    }




# get rules with hit counters
sub get_ipfstat_ih {
    my( $cmd, $key, $count, $rule, $lines );

    if ( ! -x $ipfstat ) {
	push @unknowns, "$ipfstat not found";
	return;
	}

    if ( ! -c "/dev/ipstate" ) {
	push @unknowns, "no /dev/ipstate ";
	return;
	}

    $cmd = "$sudo -S $ipfstat -ih";
    $verbose && print "+ $cmd\n";
    if ( ! open( pH, "$cmd < /dev/null 2>&1 |" ) ) {
	push @unknowns, "can't run $cmd: $!";
	return;
	}
    while( <pH> ) {
	chomp;
	$verbose && print "> $_\n";
	if ( m/^(\d+) \s+ (\S.*)/ix ) {
	    $count = $1;
	    $rule = $2;
	    $stats{ 'nrules' }++;
	    if ( $count > 0 ) { 
		$stats{ 'nrules_hit' }++;
		}
	    }
	elsif ( m/^Password:|is not in the sudoers file/ ) { 
	    # sudo configuration is wrong
	    push @unknowns, "sudoers is misconfigured";
	    return;
	    }
	elsif ( m/sudo: must be setuid root/ ) { 
	    push @unknowns, "sudo is not setuid root";
	    return;
	    }
	# else ignore it
	}
    $lines = $.;
    close pH;
    if ( $verbose ) { 
	print "lines $lines\n";
	print "rules $stats{ 'nrules' }\n";
	print "rules with hits $stats{ 'nrules_hit' }\n";
	}
    if ( $lines < 1 ) { 
	push @unknowns, "no output from ipfstat -ih";
	return;
	}
    if ( $stats{ 'nrules' } < 1 ) { 
	push @crits, "no rules in ipfstat -ih";
	return;
	}
    if ( $stats{ 'nrules_hit' } < 1 ) { 
	push @warns, "no rules with hits in ipfstat -ih";
	return;
	}
    }




sub check {

    # Is the percent state usage approaching the limit.  
    # If the number of active states reaches fr_statemax, state adds will
    # start failing.
    if ( defined $stats{ 'fr_statemax' } 
	    && $stats{ 'fr_statemax' } > 0  
	    && defined $stats{ 'active' } ) { 
	# compute percent state usage
	# Use sprintf to round to 2 decimal places so perf data doesn't 
	# get too long and truncated by nagios.
	$stats{ 'pct_state_use' } = sprintf( "%04.2f", 
	    $stats{ 'active' } * 100.0 / $stats{ 'fr_statemax' } );
	if ( $stats{ 'pct_state_use' } > 90 ) { 
	    push @crits, sprintf( "state usage is %04.2f%%", $stats{ 'pct_state_use' } );
	    }
	elsif ( $stats{ 'pct_state_use' } > 80 ) { 
	    push @warns, sprintf( "state usage is %04.2f%%", $stats{ 'pct_state_use' } );
	    }
	else { 
	    push @oks, sprintf( "%04.2f%% states used", $stats{ 'pct_state_use' } );
	    }
	}
    elsif ( defined $stats{ 'active' } )  {  
	push @oks, sprintf( "%d active", $stats{ 'active' } );
	}

    # Is the percent bucket usage getting to high?
    # This is a precursor to problems.  High bucket usage leads to hash
    # collisions, which leads to long bucket chains.
    if ( ! defined $stats{ 'pct_bkt_use' }
	    && defined $stats{ 'fr_statesize' } 
	    && $stats{ 'fr_statesize' } > 0 
	    && defined $stats{ 'bkt_use' } )  {
	# compute percent bucket usage on older ipfs that don't display it
	# Use sprintf to round to 2 decimal places so perf data doesn't 
	# get too long and truncated by nagios.
	$stats{ 'pct_bkt_use' } = sprintf( "%04.2f",
	    $stats{ 'bkt_use' } * 100.0 / $stats{ 'fr_statesize' } );
	}
    if ( defined $stats{ 'pct_bkt_use' } )  {  
	if ( $stats{ 'pct_bkt_use' } > 90.0 ) {
	    push @crits, sprintf( "bucket usage is %04.2f%%", $stats{ 'pct_bkt_use' } );
	    }
	elsif ( $stats{ 'pct_bkt_use' } > 80.0 ) {
	    push @warns, sprintf( "bucket usage is %04.2f%%", $stats{ 'pct_bkt_use' } );
	    }
	else { 
	    push @oks, sprintf( "%04.2f%% bkts used", $stats{ 'pct_bkt_use' } );
	    }
	}
    elsif ( defined $stats{ 'bkt_use' } )  {  
	push @oks, sprintf( "%d bkts used", $stats{ 'bkt_use' } );
	}

    # Is the average chain length getting too long?
    # These limits are guesses pulled out of my ass.
    if ( defined $stats{ 'avg_len' } ) { 
	if ( $stats{ 'avg_len' } > 5.0 ) {
	    push @crits, "average chain length is $stats{ 'avg_len' }";
	    }
	elsif ( $stats{ 'avg_len' } > 2.0 ) {
	    push @warns, "average chain length is $stats{ 'avg_len' }";
	    }
	else { 
	    push @oks, sprintf( "%05.3f avg len", $stats{ 'avg_len' } );
	    }
	}

    # Is the maximum chain length close to the limit?
    # If it reaches the limit, state adds will fail.
    if ( defined $stats{ 'max_len' } )  {  
	if ( ! defined $stats{ 'fr_state_maxbucket' } ) { 
	    # fake it
	    $stats{ 'fr_state_maxbucket' } = 25;
	    }

	if ( $stats{ 'max_len' } >= $stats{ 'fr_state_maxbucket' } * 0.90 ) {
	    push @crits, "maximal chain length is $stats{ 'max_len' }";
	    }
	elsif ( $stats{ 'max_len' } >= $stats{ 'fr_state_maxbucket' } * 0.80 ) {
	    push @warns, "maximal chain length is $stats{ 'max_len' }";
	    }
	else { 
	    #push @oks, sprintf( "%d max len", $stats{ 'max_len' } );
	    }
	}
    }



