#!/usr/bin/perl
use 5.004;

# Initial Setup

eval 'require "./preferences.pl"';

if ($DEMO) { print "Feature not available in DEMO mode.\n"; exit; }

# Get command line arguments

($PIDFILE, $INTERVAL, $IP, $PORTMASK, $DISCOVER) = @ARGV;
unless ($PIDFILE) { &DEBUG("Invalid (blank) pidfile; exiting."); exit; }

# Initialize globals

$EMAIL      = (eval "use Mail::Mailer")?"":$Email;
$INTERVAL   = $Autointerval if $INTERVAL > 0;
$RESTARTCMD = "$0 '$PIDFILE' '$INTERVAL' '$IP' '$PORTMASK' '$DISCOVER'";
$IP         =~ s/\*$/1-254/ if $DISCOVER;
$INTERVAL  *= 60;

# See if another instance of autorecheck is running, if so kill the process

$LOCKFILE = $Vardir."/autorecheck.lock";
if (-f $LOCKFILE) {
  open(F, "<$LOCKFILE");
  $lockpid = <F>;
  close(F);
  chomp $lockpid;
  if ($lockpid != $$) {
    &DEBUG("Lockfile exists, killing the lockfile process $lockpid");
    kill 'TERM', $lockpid;
    unlink($LOCKFILE);
    &DEBUG("$LOCKFILE deleted");
  }
}

# Now check for the configfile unless we are discovering
# If no configfile, we simply exit after cleaning up the
# lock and pid files.

unless ($DISCOVER) {
  unless (-f $Configfile) {
    unlink($PIDFILE);
    unlink($LOCKFILE);
    &DEBUG("Unable to find configfile: $Configfile\n",
           "$PIDFILE and $LOCKFILE deleted; exiting.");
    exit;
  }
}

# Get port/service default values

&ReadServices();

# If INTERVAL is negative or zero, then don't fork a process
# just run the check and exit. This is used primarily by
# the 'Recheck' button.

if ($INTERVAL > 0) {
  fork && exit;  
  close(STDIN); close(STDERR); close(STDOUT);
  use POSIX;
  POSIX::setsid();
  &DEBUG("*** Forked process: ",$$);
}

# ---- Subroutines ---- #

sub DEBUG {
    return unless $ADEBUG;
    my $caller = (caller(1))[3];
    open(BUG, ">>$Vardir/debug.log") or return;
    print BUG 'autorecheck.pl:',$caller,': ',join("",@_),"\n";
    close(BUG);
}

# Utility to decode HTML escape characters

sub Decode {
  my $text = shift || return;
  $text =~ s/%([0-9a-fA-F]{2})/chr hex($1)/ge;
  return $text;
}

# Create a directory structure based on the full file spec

sub CreateDir {
    my $filename = shift || die "Unable to create a null directory.";
    my @dirs = split(/\//, $filename);
    pop @dirs;     #Filename part
    unshift @dirs; #Root part
    my $dir = "";
    foreach (@dirs) {
      $dir .= "/" . $_;
      mkdir($dir,0775) unless -d $dir;
      die("Unable to create directory: $dir") unless -d $dir;
    }
    return 1;
}

# Convert an ip address into a file specification.

sub ip2file {
    return $Basedir . '/' . join('/', split(/\./, shift));
}

sub ReadConfigfile {
    local(*C);
    open(C, "<$Configfile") || return;
    my @out = <C>;
    close(C);
    chomp @out;
    return wantarray ? @out : join("\n",@out);
}

# Get the popular service names

sub ReadServices {
    open(S, "<$Servicesfile") || &Error("Unable to open the services file: $Servicesfile");
    while (<S>) {
      $Services{$2} = $1 if m/\s*([^:\s]+)\s*:\s*(.*)$/;
    }
    $Services{'0'} = "alive";
    close(S);
}

# Read the configuration file

sub GetConfigInfo {
    my ($ipmask,$portmask,%configinfo);
    foreach (&ReadConfigfile()) {
      ($ipmask, $portmask) = split / /;
      $configinfo{entry}{$ipmask} = $portmask;
      foreach (&GenerateSiteList($ipmask)) {
        $configinfo{ip}{$_} = join(",",&GeneratePortList($portmask));
      }
    }
    return %configinfo;
}

# Check the device up/down status.
# The device is up unless the status = down
# If the device is down and the email file does not exist, then set isdown flag
# If the device is up and the email file exists, then set isup flag and delete email file
# Otherwise, do nothing

sub CheckDeviceStatus {
    my ($host,$ip,$name,@rest) = @_;
    return unless $ip;

    my $efilename = $Basedir . '/' . join('/', split(/\./, $ip)) . ".email";
    my $isdown    = "";
    my $isup      = "";

    if ($rest[0] =~ /^Status: ((?:Down|Timeout))/) {
      my $type = $1;
      unless (-f $efilename) {
        open(E, ">$efilename"); print E time," $type"; close(E);
        $isdown = ($type eq "Down")?"Device not responding":"Device has timed-out";
      }
    } elsif (-f $efilename) {
      $isup = "Device Recovery";
      unlink($efilename);
    }
    
    return ($isup,$isdown);
}

# Check the status of each of a device's port list.
# The method used is to examine the list of currently open
# ports against the ports that are being monitored.
# Set the state of all ports missing from the monitored port
# list to down and the state of all other ports to up.
# Next, check the port history file. Delete from the
# state list all ports that match both the number and state
# The remaining ports are those that have transitioned state
# and should be reported.

sub CheckPortStatus {
    my($ip,$ports) = @_;
    return unless ($ip and ($ports =~ /^Ports: /));

    my %portstatus;

# Get list of monitored ports and set as default down

    foreach (split(/,/,$ConfigInfo{ip}{$ip})) { $portstatus{$_} = 'down'; }

# Get list of currently up ports

    $ports =~ s/^Ports: //;
    my @currentports;
    foreach (split(/,\s*/, $ports)) { push(@currentports, (split(/\//,$_))[0]); }

# Update the portstatus hash

    foreach (@currentports) { $portstatus{$_} = 'up' if $portstatus{$_}; }

# Remember for later

    my %saveportstatus = %portstatus;

# Delete all ports that have not changed state

  	my $filename = &ip2file($ip) . ".portstate";
    if (open(PORTS, "<$filename")) {
      while (<PORTS>) {
        chomp;
        ($port,$state) = split / /;
        delete $portstatus{$port} if $state eq $portstatus{$port};
      }
      close(PORTS);
    } else {                # If the portstate file does not exist, then delete all
      undef %portstatus;
    }

# If there are any items in %portstatus then set the appropriate message flag

    my $up = "";
    my $dn = "";
    foreach (sort {$a <=> $b} keys %portstatus) {
      $up .= $_.($Services{$_}?'('.$Services{$_}.')':'').',' if $portstatus{$_} eq 'up';
      $dn .= $_.($Services{$_}?'('.$Services{$_}.')':'').',' if $portstatus{$_} eq 'down';
    }
    chop $up; chop $dn;
    $up = 'Port'.(($up =~ /,/)?'s':'').' '.$up.' recovered'      if $up;
    $dn = 'Port'.(($dn =~ /,/)?'s':'').' '.$dn.' not responding' if $dn;

# Record the status of each of the ports for next time

    if (open(PORTS, ">$filename")) {
      foreach (sort {$a <=> $b} keys %saveportstatus) {
        print PORTS $_.' '.($portstatus{$_}?$portstatus{$_}:$saveportstatus{$_})."\n";
      }
      close(PORTS);
    }
    
    return ($up,$dn);
}

# Write an event to the event log and send an Email message if Email is enabled and
# The event is not an 'auto' event

sub WriteEvent {
    my($time,$ip,$type,$event,$name) = @_;
    return unless ($time and $ip and $event and ($type >= $EventSeverity));
    return unless ($Eventlogfile and ($MaxEventLogSize > 0));
    my @events;

    if (open(E, "<$Eventlogfile")) {
      @events = <E>;
      close(E);
      chomp @events;
      &DEBUG("Found ",($#events+1)," events");
      @events = splice(@events,$#events-$MaxEventLogSize) if $#events >= $MaxEventLogSize;
    }

    push(@events,join("\t",$time,$ip,$type,$event));
    open(E, ">$Eventlogfile.tmp") or return;
    print E join("\n",@events),"\n";
    close(E);
    rename "$Eventlogfile.tmp", $Eventlogfile;

    unless ($ip =~ /^Auto/i) {
      &DEBUG("Email : ",$event,"\t",$ip,"\t",$EMAIL,"\t");
      &SendMail($event, $ip, $time, $name);
    } 
 
    &DEBUG("Event logged");
}

# Send an Email message containing an nPulse status message to $EMAIL

sub SendMail {
    my($event, $ip, $time, $name) = @_;
    return unless ($event and $ip and $EMAIL);
    
    # See if there are additional 'cc' addresses for this ip address
    my @To = ($EMAIL);
    if (open(CC, "<$Infofile")) {
      foreach (grep(/ip => "$ip"/,<CC>)) {
        chomp;
        /^.*Notify_[0-9]+ => "(.*?)".*$/;
        push(@To,split(/\s*,\s*/,&Decode($1))) if $1;
      }
      close(CC);
    }

    my $mail = new Mail::Mailer;
    eval "use Sys::Hostname; $hostname = hostname();";
    my $from = $myName.' <'.($< || 'root').'@'.($hostname || $ENV{'HOSTNAME'} || 'localhost').'>'; 
    $name ||= $ip;
    $mail->open( { From => $from,
                   To   => \@To,
                   Subject => "nPulse: $event [$ip]" } ) || die $!;
    print $mail "Status message from $from\nnPulse reports $event for $name [$ip]\n\nAt: $time\n";
    close($mail);
}

# Return an array of numeric IP addresses based on an ipmask.
# The mask can contain #-# or #,# or any combination.
# There is probably a better way to do this, but this works!

sub GenerateSiteList {
    my(@sites) = ();
    my($ip1, $ip2, $ip3, $ip4, @ips);

    my($ipmask) = shift || return;
    @ips = split(/\./, $ipmask);
    return unless $#ips == 3;
    foreach $i (0..3) {
      $ips[$i] =~ s/\-/\.\./g;
      $ips[$i] =~ s/\*/0\.\.255/g;
      $ips[$i] =~ s/^00?(\d)/$1/g;
      $ips[$i] =~ s/(\D)00?(\d)/$1$2/g;
    }
    foreach $ip1 (eval($ips[0])) {
      next unless $ip1 =~ /^\d+$/;
      foreach $ip2 (eval($ips[1])) {
        next unless $ip2 =~ /^\d+$/;
        foreach $ip3 (eval($ips[2])) {
          next unless $ip3 =~ /^\d+$/;
          foreach $ip4 (eval($ips[3])) {
            next unless $ip4 =~ /^\d+$/;
            push(@sites, $ip1.'.'.$ip2.'.'.$ip3.'.'.$ip4);
          }
        }
      }
    }
            
    return wantarray ? @sites : join("\n",@sites);
}

# Return a sorted array of port numbers based on a portmask
# The mask is of the form:
# <num>[,<num>][-<num>] or *
# for example:
#  9                 a single port (9)
#  1-100             a range of ports from 1 to 100 inclusive
#  10,11,15          a series of ports
#  7,12,90-94,15-17  is the series: 7,12,15,16,17,90,91,92,93,94
#  *                 is the range 1-65535

sub GeneratePortList {
    my($portmask) = shift;
    my(@ports);
    if ($portmask =~ /\*/) {
      $portmask = "1..65535";
    } else {
      $portmask =~ s/^[^0-9\., -]//g;
      $portmask =~ s/-/../g;
    }
    foreach (eval($portmask)) {
      push(@ports, $_);
    }
    return sort {$a <=> $b} @ports;
}

# Save the scan result for this ip into the appropirate history file

sub RecordHistory {
    my($ip,$name,@rest) = @_;
    return unless $ip;

    my $filename = $Basedir . '/' . join('/', split(/\./, $ip)) . ".hist";
    &CreateDir($filename) unless -f $filename;
    
    open(G, ">>$filename") || exit;
    print G time,"\tHost: ",$ip," (",$name,")\t",join("\t",@rest),"\n";
    close(G);
}

sub signal_handler {
    $time_to_die = 1;                           # Exits the main "until" loop
    $time_to_restart = 0;                       # We don't want to restart ourself
    &DEBUG("TERM or INT signal received.");
}

sub signal_hup {
    $time_to_die = 1;                           # Exit the main "until" loop
    $time_to_restart = 1;                       # We want to restart ourself
    &DEBUG("HUP signal received.");
}

# ---- End Subroutines ---- #

# Now for the main code.
# First make sure we trap the signals sent to us.

$SIG{INT} = $SIG{TERM} = \&signal_handler;
$SIG{HUP} = \&signal_hup;

# Announce our presence via a PID file

open(PID, ">$PIDFILE") || exit;
print PID $$; close(PID);
&DEBUG("$PIDFILE created");

# Main Loop. Continue until we receive a signal to stop or restart

until ($time_to_die) {

# Perform a sanity check. If the current PID file is not the same as
# our PID then we have become detached somehow, so just exit

  open(PID, "<$PIDFILE") || exit;
  $pid = <PID>; close(PID);
  chomp $pid;
  exit unless $pid == $$;

# Let other processes know that we are executing (not sleeping)
  
  open(LOCK, ">$LOCKFILE") || exit;
  print LOCK $$; close(LOCK);
  &DEBUG("$LOCKFILE created");

# Get the current configfile (just in case it has changed while we were sleeping!

  %ConfigInfo = &GetConfigInfo();

# Record our (re)starting in the event log
  
  &DEBUG("**************************************\n   ",scalar(localtime(time)),"\n   DISCOVER    = $DISCOVER\n   PORTMASK    = $PORTMASK\n   IP          = $IP\n   email       = $EMAIL\n   INTERVAL    = $INTERVAL\n");
  &WriteEvent(scalar(localtime(time)),'Auto',$Stat_ok,($DISCOVER?'Discovery':'AutoCheck').' Started','');

# Figure out what nmap commands we need to run and in what order.
# Options are
# 1. DISCOVER => single nmap command searching the (sub)net
# 2. PORTMASK => single nmap command on a specific IP with a range of ports
# 3. IP       => single nmap command on a specific IP
# 4. else     => multiple nmap commands based on the IP/PORTS in the configfile

  undef @cmds;
  if ($DISCOVER)    { push(@cmds, $Nmapcmd." ".$nmap_general_options." ".$nmap_extra_options." -v -oM - ".$NmapScan." ".($PORTMASK?"-p '".$PORTMASK."' ":"")."'".$IP."' 2>/dev/null | grep \"Host: \""); }
  elsif ($PORTMASK) { push(@cmds, $Nmapcmd." ".$nmap_general_options." ".$nmap_extra_options." -v -oM - ".$NmapScan." -p '".$PORTMASK."' '".$IP."' 2>/dev/null | grep \"Host: \""); }
  elsif (!$IP)      { foreach $ipmask (keys %{$ConfigInfo{entry}}) { $portmask = $ConfigInfo{entry}{$ipmask}; push(@cmds, $Nmapcmd." ".$nmap_general_options." ".$nmap_extra_options." -v -oM - ".($portmask ? $NmapScan." -p '".$portmask."'":"-sP")." '".$ipmask."' 2>/dev/null | grep \"Host: \""); } }
  else              { push(@cmds, $Nmapcmd." ".$nmap_general_options." ".$nmap_extra_options." -v -oM - ".$NmapScan." '".$IP."' 2>/dev/null | grep \"Host: \""); }
  
# Execute the command(s) and capture the output
  
  undef @output;
  foreach $cmd (@cmds) {
    &DEBUG("Running cmd: $cmd");
    @out = `$cmd`;
    chomp @out;
    push(@output, @out);
  }
  &DEBUG("Finished commands\n",
         "Found ",($#output+1)," output items");

# If "discovery" mode write our results to the Configuration file.
# Note, the editor.pl routine makes a backup copy of the configfile
# before calling this routine.

# Otherwise record the results to a temporary file for now
  
  if ($DISCOVER) {
    open(F, ">$Configfile") || exit;
    $INTERVAL = 0;                              # Single run
    $EMAIL = "";                                # No Email messages
  } else {
    $tempCurrentfile = $Currentfile.'.tmp';
    open(F, ">$tempCurrentfile") || exit;
  }

# Parse the results
# nmap results are in one of the following forms:
# Host:  (<ip>)<tab>Status: Down
# Host: <ip> (<hostname>)<tab>Status: Up
# Host:  (<ip>)<tab>Ports: <num>/open/tcp//<service>///, <num>/open/tcp//<service>///, ...<tab>other info
# Host: <ip> (<hostname>)<tab>Ports: <num>/open/tcp//<service>///, <num>/open/tcp//<service>///, ...<tab>other info
#
# where <tab>      is a tab character
#       <ip>       is the numeric ip address: xxx.xxx.xxx.xxx
#       <hostname> is the ip name (string), if available
#       <num>      is the port number
#       <service>  is the service name (string) for the port, if available

  foreach (@output) {
    ($host, @rest) = split /\t/;
    $host =~ s/^Host: //;
    ($ip, $name) = split(/ /, $host);
    $name =~ s/\(|\)//g;
    unless ($ip) { $ip = $name; $name = ""; }

# If we are in "discovery" mode, record the ip address and the list of open ports

    if ($DISCOVER) {
      $ports = $rest[0];
      next unless $ports =~ /^Ports: /;
      $ports =~ s/^Ports: //;
      $portmask = "";
      foreach (split(/,\s*/, $ports)) { $portmask .= (split(/\//,$_))[0].","; }
      chop $portmask;
      print F $ip," ",$portmask,"\n";
      &DEBUG("Discovered ",$ip," ",$portmask);

# Otherwise record the result in the appropirate history file, check the device
# and port status, and record the event (email if appropriate)

    } else {

      &RecordHistory($ip,$name,@rest);

      ($isup,$isdown) = &CheckDeviceStatus($host,$ip,$name,@rest);
      &WriteEvent(scalar(localtime(time)),$ip,$Stat_ok,$isup,$name)     if $isup;
      &WriteEvent(scalar(localtime(time)),$ip,$Stat_fail,$isdown,$name) if $isdown;
      
      unless ($isdown) {    # Don't report all the port failures if the device is down
        ($up,$dn) = &CheckPortStatus($ip,join("\t",@rest));
        &WriteEvent(scalar(localtime(time)),$ip,$Stat_ok,$up,$name)   if $up;
        &WriteEvent(scalar(localtime(time)),$ip,$Stat_warn,$dn,$name) if $dn;
      }

# Put the result into the currentfile for nPulse's use

      print F "Host: ",$ip," (",$name,")\t",join("\t",@rest),"\n";
    }
  }

# Close the configfile or currentfile as appropriate

  close(F);

# Make the temporary current file active by renaming it unless we are in "discovery" mode

  rename($tempCurrentfile, $Currentfile) unless $DISCOVER;

# Let others know we are either done or going to sleep by deleting the lock file
  
  unlink($LOCKFILE);
  &DEBUG("$LOCKFILE deleted");
  &WriteEvent(scalar(localtime(time)),'Auto',$Stat_ok,($DISCOVER?'Discovery':'AutoCheck').' Completed','');

# If we were called in "daemon" mode, then sleep and repeat.
# Otherwise, its time to exit.

  if ($INTERVAL > 0) { &DEBUG("Sleeping for $INTERVAL seconds"); sleep($INTERVAL); }
  else               { $time_to_die = 1; }
}

# It's time to exit. First, delete the PID and any LOCK files.

unlink($PIDFILE);
unlink($LOCKFILE);
&DEBUG("$PIDFILE and $LOCKFILE deleted");

# If we were asked to restart then do so, otherwise we are done.

if ($time_to_restart) {
  &DEBUG("Restarting: ",$RESTARTCMD);
  exec($RESTARTCMD);
}
&DEBUG("Exiting");
exit;

