#!/usr/bin/perl -T
# $RedRiver: check_hw_sensors,v 1.42 2009/11/24 17:54:54 andrew Exp $
########################################################################
# check_hw_sensors *** A nagios check for OpenBSD sysctl hw.sensors
#
# 2006.05.01 #*#*# andrew fresh <andrew@afresh1.com>
########################################################################
use strict;
use warnings;

local %ENV = ();

use POSIX;
use Config;
use Getopt::Long;
use List::Util qw/ first /;

my $NAGIOS_OUTPUT = 1;

our $VERSION = q{$Revision: 1.42 $}; $VERSION =~ s/^\D+([\d\.]+)\D+$/v$1/xms;

my $LICENSE = <<'EOL';
Copyright (c) 2009 Andrew Fresh <andrew@afresh1.com>
Permission to use, copy, modify, and distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.

THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
EOL

my $PROGNAME       = 'check_hw_sensors';
my $SYSCTL         = '/sbin/sysctl';
my $GETCAP         = '/usr/bin/getcap';
my $BASE           = 'hw.sensors';
my $DEFAULT_CONFIG = '/etc/sensorsd.conf';

my $PREFIX;

BEGIN {
    ## no critic 'warnings'
    no warnings 'uninitialized';
    $PREFIX = "/usr/local" || '/usr/local';    # Magic for OpenBSD ports tree
}
use lib $PREFIX . '/libexec/nagios';
use utils qw($TIMEOUT %ERRORS &support);

$SIG{'ALRM'} = sub {
    print "ERROR: $PROGNAME timeout\n";
    exit $ERRORS{'UNKNOWN'};
};
alarm $TIMEOUT;

Getopt::Long::Configure('bundling');

my $OSVer = $Config{'osvers'} || 0;

my $state = 'UNKNOWN';    # tells whether the it is warning, critical, or OK
my $opt_V;
my $opt_h;
my $IGNORE_STATUS;
my $FILENAME;
my $SENSOR;
my $WARNING;
my $CRITICAL;

#Option checking
my $getopt_status = GetOptions(
    'version|V'       => \$opt_V,
    'help|h'          => \$opt_h,
    'ignore-status|i' => \$IGNORE_STATUS,
    'filename|f:s'    => \$FILENAME,
    'sensor|s=s'      => \$SENSOR,
    'warning|w=s'     => \$WARNING,
    'critical|c=s'    => \$CRITICAL,
);

if ( $getopt_status == 0 ) {
    print_help();
    exit $ERRORS{'OK'};
}

if ($opt_V) {
    print_revision( $PROGNAME, $VERSION );
    exit $ERRORS{'OK'};
}

if ($opt_h) {
    print_help();
    exit $ERRORS{'OK'};
}

# set the default this way so it only happens if someone typed -f or --filename
if ( defined $FILENAME && $FILENAME eq q{} ) {
    $FILENAME = $DEFAULT_CONFIG;
}

# Stuff is output in this file by print_sensor()
# http://www.openbsd.org/cgi-bin/cvsweb/src/sbin/sysctl/sysctl.c
my @TYPE_MAP = (
    {   type  => 'temp',
        regex => qr/\sdegC$/xms,
    },
    {   type  => 'fanrpm',
        regex => qr/\sRPM$/xms,
    },
    {   type  => 'volts_dc',
        regex => qr/\sV\sDC$/xms,
    },
    {   type  => 'amps',
        regex => qr/\sA$/xms,
    },
    {   type  => 'watthour',
        regex => qr/\sWh$/xms,
    },
    {   type  => 'amphour',
        regex => qr/\sAh$/xms,
    },
    {   type  => 'indicator',
        regex => qr/^(On|Off)$/xms,
    },
    {   type  => 'integer',
        regex => qr/\sraw$/xms,
    },
    {   type  => 'percent',
        regex => qr/\s\%$/xms,
    },
    {   type  => 'lux',
        regex => qr/\slx$/xms,
    },
    {   type  => 'drive',
        regex => qr/^drive\s/xms,
    },
    {   type  => 'timedelta',
        regex => qr/\ssecs$/xms,
    },
);

my $CHECK_SENSOR = $BASE;
my %CHECKS;
if ( defined $SENSOR ) {
    if ( $SENSOR !~ /^$BASE/xms ) {
        $SENSOR = join q{.}, $BASE, $SENSOR;
    }
    $CHECK_SENSOR = $SENSOR;

    if ($WARNING)  { $CHECKS{$SENSOR}{'warn'} = $WARNING; }
    if ($CRITICAL) { $CHECKS{$SENSOR}{'crit'} = $CRITICAL; }
}
elsif ( defined $FILENAME ) {
    %CHECKS = read_file($FILENAME);
}

my @SENSORS = read_sensors($CHECK_SENSOR);
my %STATES = check_sensors( \@SENSORS, \%CHECKS,
    { IGNORE_STATUS => $IGNORE_STATUS } );

my $have_results = 0;
$state = 'OK';
foreach
    my $error ( reverse sort { $ERRORS{$a} <=> $ERRORS{$b} } keys %ERRORS )
{
    if ( exists $STATES{$error} ) {
        $have_results++;
        $state = $error if $ERRORS{$state} < $ERRORS{$error};

        if ($NAGIOS_OUTPUT) {
            print $error . ' (' . scalar( @{ $STATES{$error} } ) . ')';
            if ( $error ne 'OK' ) {
                print '<br>';
                print map {" - $_<br>"} @{ $STATES{$error} };
            }
        }
        else {
            print $error . ' (' . scalar( @{ $STATES{$error} } ) . "):\n";
            foreach ( @{ $STATES{$error} } ) {
                print "   $_\n";
            }
        }
    }
}
if ( $have_results == 0 ) {
    print "No results found\n";
}
exit $ERRORS{$state};

sub read_sensors {
    my ($sensor) = @_;
    my @S;
    open my $sysctl, q{-|}, $SYSCTL, $sensor
        or die "Couldn't open sysctl: $!\n";
    while (<$sysctl>) {
        chomp;
        push @S, parse_sensor($_);
    }
    ## no critic 'die'
    close $sysctl
        or die $!
        ? "Error closing sysctl pipe: $!\n"
        : "Exit status $? from sysctl\n";

    return @S;
}

sub parse_sensor {
    my ($sensor) = @_;

    ## no critic 'literal'
    my ( $id, $output ) = split /=/xms, $sensor;
    my @s = split /\./xms,   $id;
    my @o = split /,\s*/xms, $output;

    my ( $type, $source, $descr, $data, $status );

    $source = $o[0];
    $descr  = $o[1];

    if ( $OSVer >= 4.1 ) {
        $data = $o[0];
        if ( $data =~ s/\s+\((.*)\).*$//xms ) {
            $descr = $1;
        }
        $status = $o[1];
        ( $source, $type ) = $id =~ /([^\.]+)\.([^\.]+?)\d+$/xms;
    }
    elsif ( $OSVer >= 4.0 ) {
        $data   = $o[2];
        $status = $o[3];
        foreach my $t (@TYPE_MAP) {
            if ( $data =~ /$t->{'regex'}/xms ) {
                $type = $t->{'type'};
                last;
            }
        }
    }
    else {
        $data   = $o[-1];
        $status = $o[2] if @o == 5;
        $type   = $o[-2];
    }

    $type ||= 'unknown';

    return {
        id          => $id,
        output      => $output,
        source      => $source,
        description => $descr,
        status      => $status,
        type        => $type,
        data        => $data,
    };
}

sub read_file {
    my $filename = shift;
    my %contents;

    die "file '$filename' does not exist.\n" if !-e $filename;

    open my $fh, q{-|}, $GETCAP, q{-a}, q{-f}, $filename
        or die "Couldn't open '$GETCAP -a -f $filename': $!\n";
    while (<$fh>) {
        chomp;
        my ( $s, @c ) = split /\:/xms;
        $contents{$s} = parse_line(@c);
    }
    ## no critic 'die'
    close $fh
        or die $!
        ? "Error closing getcap pipe: $!\n"
        : "Exit status $? from getcap\n";

    return %contents;
}

sub parse_line {
    my (@c) = @_;
    my %c;
    foreach (@c) {
        my ( $k, $v ) = split /\=/xms;
        if    ( lc($k) eq 'ignore' ) { $c{'IGNORE'} = 1; }
        elsif ( lc($k) eq 'status' ) { $c{'STATUS'} = 1; }
        else                         { $c{$k}       = $v; }
    }
    return \%c;
}

sub parse_check {
    my $check = shift;

    return          if !$check;
    return 'STATUS' if $check->{'STATUS'};
    return 'IGNORE' if $check->{'IGNORE'};

    foreach my $code ( 'crit', 'warn' ) {
        if ( defined $check->{$code} && $check->{$code} =~ /:/xms ) {
            if ( my ( $low, $high ) = split /:/xms, $check->{$code} ) {
                $check->{ $code . '.low' }  = $low  if length $low;
                $check->{ $code . '.high' } = $high if length $high;
            }
            delete $check->{$code};
        }

        foreach my $direction ( 'low', 'high' ) {
            my $c = $code . q{.} . $direction;
            if ( defined $check->{$direction} ) {
                $check->{$c} ||= $check->{$direction};
            }

            if ( defined $check->{$c} ) {
                my $old = $check->{$c};
                $check->{$c} =~ s/[^\d\.]//gxms;
                if ( !length $check->{$c} ) {
                    warn "INVALID CHECK ($old)\n";
                    delete $check->{$c};
                }
            }
        }

        if ( defined $check->{$code} ) {
            $check->{$code} = [ split /,\s*/xms, $check->{$code} ];
        }
        else {
            $check->{$code} = [];
        }
    }

    return $check;
}

sub check_sensors {
    my ( $S, $C, $O ) = @_;

    my %states;
    foreach my $sensor ( @{$S} ) {
        my ( $r, $data );
        if ( exists $C->{ $sensor->{id} } ) {
            $r = check_sensor( $sensor, $C->{ $sensor->{id} } );
            $data = $sensor->{id} . q{=} . $sensor->{output};
        }
        elsif ( $sensor->{status} && !$O->{IGNORE_STATUS} ) {
            $r = check_sensor( $sensor, { STATUS => 1 } );
            $data = $sensor->{id} . q{=} . $sensor->{output};
        }
        else {

            # ignore this sensor, theoretically you could do the check and
            # that would show unknown sensors.
        }
        if ( defined $r ) {
            push @{ $states{$r} }, $data;
        }
    }

    return %states;
}

sub check_sensor {
    my ( $sensor, $check ) = @_;
    my $result = 'UNKNOWN';

    return $result if ref $sensor ne 'HASH';
    $check = parse_check($check) if $check;

    if ( !$check ) { return $result; }
    elsif ( $check eq 'STATUS' ) {

        # It looks like returning $sensor->{status} should be safe, from
        # src/sbin/sysctl/sysctl.c
        return ( $sensor->{'status'} || $result );
    }
    elsif ( $check eq 'IGNORE' ) { return; }

    my $type = $sensor->{'type'};
    if (first { $type eq $_ }
        qw(
        fan fanrpm
        volt volts_dc
        amps watthour amphour
        integer raw percent
        lux temp timedelta
        )
        )
    {
        $result = check_sensor_numeric( $sensor->{'data'}, $check );
    }
    elsif ( first { $type eq $_ } qw( drive indicator ) ) {
        my $data = $sensor->{'data'};
        $data =~ s/^drive\s+//xms;
        $result = check_sensor_list( $data, $check );
    }
    else {
        warn "Unknown Sensor Type: $sensor->{id} = $type\n";
    }

    return $result;
}

sub check_sensor_numeric {
    my ( $data, $check ) = @_;

    my $result = 'UNKNOWN';
    my %errors = (
        'warn' => 'WARNING',
        'crit' => 'CRITICAL',
    );

    $data =~ s/[^\d\.]//gxms;
    if ( !length $data ) {
        warn "INVALID DATA ($data)\n";
        return $result;
    }

    foreach my $code ( 'warn', 'crit' ) {
        if (   defined $check->{ $code . '.low' }
            || defined $check->{ $code . '.high' } )
        {
            if ((   defined $check->{ $code . '.low' }
                    && $check->{ $code . '.low' } >= $data
                )
                || ( defined $check->{ $code . '.high' }
                    && $check->{ $code . '.high' } <= $data )
                )
            {
                $result = $errors{$code};
            }
            $result = 'OK' if $result eq 'UNKNOWN';
        }
        elsif ( @{ $check->{$code} } ) {
            my $matched = 0;
        NUMERIC_CHECK: foreach ( @{ $check->{$code} } ) {
                my $c = $_;
                $c =~ s/[^\d\.]//gxms;
                if ( !length $c ) {
                    warn "INVALID CHECK ($_) for '$code'\n";
                    next;
                }

                if ( $c eq $data ) {
                    $matched = 1;
                    last NUMERIC_CHECK;
                }
            }
            if ($matched) {
                $result = 'OK' if $result eq 'UNKNOWN';
            }
            else {
                $result = $errors{$code};
            }
        }
    }

    return $result;
}

sub check_sensor_list {
    my ( $data, $check ) = @_;

    my $result = 'UNKNOWN';
    my %errors = (
        'warn' => 'WARNING',
        'crit' => 'CRITICAL',
    );

    foreach my $code ( 'warn', 'crit' ) {
        if ( @{ $check->{$code} } ) {
            my $matched = 0;
        LIST_CHECK: foreach ( @{ $check->{$code} } ) {
                if ( $_ eq $data ) {
                    $matched = 1;
                    last LIST_CHECK;
                }
            }
            if ($matched) {
                $result = 'OK' if $result eq 'UNKNOWN';
            }
            else {
                $result = $errors{$code};
            }
        }
    }

    return $result;
}

sub print_help {
    print <<"EOL";
$PROGNAME - monitors sysctl hw.sensors on OpenBSD
    $PROGNAME [-i] [-f [<FILENAME>]|(-s <hw.sensors id> [-w limit] [-c limit])]

Usage:
    -i, --ignore-status
        Don't automatically check the status of sensors that report it.
    -f, --filename=FILE
        FILE to load checks from (defaults to /etc/sensorsd.conf)
    -s, --sensor=ID
        ID of a single sensor.  "-s kate0.temp0" means hw.sensors.kate0.temp0
        Overrides --filename.
    -w, --warning=RANGE or single ENTRY
        Exit with WARNING status if outside of RANGE or if != ENTRY
    -c, --critical=RANGE or single ENTRY
        Exit with CRITICAL status if outside of RANGE or if != ENTRY

FILE is in the same format as sensorsd.conf(5) plus some additional
entries.  These additional entries in the file are ignored by
sensorsd(8).  This means you can use the same config file for $PROGNAME
as well as sensorsd(8).

$PROGNAME understands the following entries:

    low, high, crit, warn, crit.low, crit.high, warn.low, warn.high,
    ignore, status

An ENTRY depends on the type.  The descriptions in sensorsd.conf(5)
can be used when appropriate, or you can use the following:

    fanrpm, volts_dc, amps, watthour, amphour, integer (raw), percent,
    lux, temp or timedelta - Anything that includes digits.  Both the
    value of the check and the value of the sensor response that are not
    either a digit or period are stripped and then the two resultant
    values are compared.

    indicator or drive - does a case sensitive match of each
    entry in the comma separated list and if it does not match
    any of the entries, it sets the status.

The entries 'crit' or 'warn' (or the -c or -w on the command line)
may be a RANGE or a comma separated list of acceptable values.
The comma separated list of values contains a list of things that
will NOT cause the status.  This is possibly counterintuitive, but
you are more likely to know good values than bad values.

A RANGE is a low ENTRY and a high ENTRY separated by a colon (:).
It can also be low: or :high with the other side left blank to only
make the single check.

An entry marked "ignore" will cause that sensor to be skipped.
Generally used with state checking of all sensors to ignore sensors you
don't care about or that report incorrectly.

If you are using --ignore-status, you can still check the status of
individual sensors with a status entry.

EOL

    print_revision( $PROGNAME, $VERSION );

    print $LICENSE;

    return;
}

sub print_revision {
    my ( $prog, $rev ) = @_;

    print "$prog $rev\n";

    return;
}

