#!/usr/bin/perl -T
# iWatch
# By Cahya Wirawan <cahya at gmx dot at>
# Usage in daemon mode: 
# iwatch [-f <configfile.xml>] [-d] [-v] [-p <pid file>]
# Usage in command line mode:
# iwatch [-c command] [-e event[,event[,..]]] [-m email] [-r] [-t filter] [-x exception] <target>
# iWatch monitor any changes in directories/files specified
# in the configuration file, and send email alert.
# This program needs inotify in linux kernel >= 2.6.13  

use strict;
use Getopt::Std;
use Event;
use Linux::Inotify2;
use File::Find;
use Mail::Sendmail;
use Sys::Hostname;
use XML::Simple;
use POSIX;
use Sys::Syslog;
#use Data::Dumper;

my $PROGRAM = "iWatch";
my $VERSION = "0.0.12";
my $VERBOSE = 0;
my $CONFIGFILE = "/etc/iwatch.xml";
my $PIDFILE = "/var/run/iwatch.pid";
my $config;
my %WatchList;
my %Mail;
my %Events;
my $Filename;
sub Usage;
sub wanted;
sub mywatch;
sub pathwatch;
sub getMask;
sub stringf;
$Getopt::Std::STANDARD_HELP_VERSION = 1;

my %options=();
my %formats = (
  'p' => sub { "$PROGRAM" },
  'v' => sub { "$VERSION" },
  'f' => sub { 
  	$Filename =~ s/([;<>\*\|`&\$!#\(\)\[\]\{\}:'" \\])/\\$1/g; 
  	"$Filename"; 
  },
);
my %InotifyEvents = (
  'access' => IN_ACCESS,
  'modify' => IN_MODIFY,
  'attrib' => IN_ATTRIB,
  'close_write' => IN_CLOSE_WRITE,
  'close_nowrite' => IN_CLOSE_NOWRITE,
  'open' => IN_OPEN,
  'moved_from' => IN_MOVED_FROM,
  'moved_to' => IN_MOVED_TO,
  'create' => IN_CREATE,
  'delete' => IN_DELETE,
  'delete_self' => IN_DELETE_SELF,
  'move_self' => IN_MOVE_SELF,
  'unmount' => IN_UNMOUNT,
  'q_overflow' => IN_Q_OVERFLOW,
  'ignored' => IN_IGNORED,
  'close' => IN_CLOSE,
  'move' => IN_MOVE,
  'isdir' => IN_ISDIR,
  'oneshot' => IN_ONESHOT,
  'all_events' => IN_ALL_EVENTS,
  'default' => IN_CLOSE_WRITE|IN_CREATE|IN_DELETE|IN_MOVE|IN_DELETE_SELF|IN_MOVE_SELF,
);
my %InotifyEventNames;
foreach my $EventName (keys %InotifyEvents) {
  $InotifyEventNames{$InotifyEvents{$EventName}} = "IN_\U$EventName";
}

my $opt = getopts("vdf:p:c:e:hm:rst:w:x:",\%options);

if(defined $options{h} || $opt == 0) {
  Usage();
  exit 0;
}

openlog("$PROGRAM", 'cons,pid', 'user');
$CONFIGFILE = $options{f} if(defined $options{f});
if($options{p}) {
  #foo the taint mode, if the admin wants crazy filenames, so let him
  $options{p} =~ /(.*)/; 
  $PIDFILE = $1;
}

$VERBOSE += 2 if defined $options{v};
delete @ENV{qw(ENV IFS CDPATH)};
$ENV{PATH} = "/bin:/usr/bin:/usr/sbin";

if((defined $options{d} || defined $options{f} || defined $options{p}) && 
   (-e $ARGV[0] || defined $options{c} || defined $options{e} || defined $options{m} 
     || defined $options{r} || defined $options{s} || defined $options{t} || defined $options{w} || defined $options{x})) {
     print STDERR "Options [d|f|p] and [c|e|m|r|s|w|x] are mutually exlusive, you can't mix it!\n";
     Usage();
     exit 1;
   }

if(defined $options{w} || -e $ARGV[0]) {
  $VERBOSE += 1;
  $config->{guard}->{email} = (getpwuid($>))[0] . "\@localhost";
  $config->{watchlist}[0]->{path}[0]->{content} = (-e $ARGV[0])? $ARGV[0] : $options{w};
  $config->{watchlist}[0]->{path}[0]->{events} = 
    (defined $options{e}) ? $options{e} : "default";
  $config->{watchlist}[0]->{path}[0]->{type} = 
    (defined $options{r}) ? "recursive" : "single";
  if(defined $options{c}) {
  	$config->{watchlist}[0]->{path}[0]->{exec} = $options{c};
  }
  if(defined $options{m}) {
  	$config->{watchlist}[0]->{path}[0]->{alert} = "on";
  	$config->{watchlist}[0]->{contactpoint}->{email} = $options{m};
  }
  else {
  	$config->{watchlist}[0]->{path}[0]->{alert} = "off";
  }
  if(defined $options{s}) {
  	$config->{watchlist}[0]->{path}[0]->{syslog} = "on";
  }
  else {
  	$config->{watchlist}[0]->{path}[0]->{syslog} = "off";
  }
  if(defined $options{t}) {
  	$config->{watchlist}[0]->{path}[0]->{filter} = $options{t};
  }
  if(defined $options{x}) {
    $config->{watchlist}[0]->{path}[1]->{content} = $options{x};
    $config->{watchlist}[0]->{path}[1]->{type} = "exception";
  }
}
else {
  if(! -f $CONFIGFILE) {
    Usage();
  	exit 1;
  }
  $config = XMLin($CONFIGFILE, ForceArray => ['path','watchlist']);
}

#print Dumper($config);

if(defined $options{d}) {
  my $ChildPid = fork;

  if($ChildPid) {
    open(FH, '>', "$PIDFILE") or die "Could not write to pidfile \"$PIDFILE\": $!";
    print FH "$ChildPid"; 
    close FH; 
  }

  die "Can't fork: $!\n" if(!defined $ChildPid);
  exit if($ChildPid);

  POSIX::setsid() or die "Can't start a new session: $!";
  open STDIN, "</dev/null";
  open STDOUT, ">/dev/null";
  open STDERR, ">&STDOUT";
  umask 0;
  chdir "/";
}

my $inotify = new Linux::Inotify2;
Event->io (fd => $inotify->fileno, poll => 'r', cb => sub { $inotify->poll });

foreach my $watchpoint (@{$config->{watchlist}}) {
  foreach my $path (@{$watchpoint->{path}}) {
    next if($path->{type} ne "exception");
  	if(-d $path->{content}) { $path->{content} =~ s/(.+)\/$/$1/;};
  	$WatchList{$path->{type}}{$path->{content}}{"type"} =  $path->{type};
  }
}

foreach my $watchpoint (@{$config->{watchlist}}) {
  foreach my $path (@{$watchpoint->{path}}) {
  	next if($path->{type} eq "exception");
  	if(-d $path->{content}) { $path->{content} =~ s/(.+)\/$/$1/;};
    $WatchList{$path->{type}}{$path->{content}}{"contactpoint"} = $watchpoint->{contactpoint}->{email};
    $WatchList{$path->{type}}{$path->{content}}{"exec"} = $path->{exec} if(defined($path->{exec}));
    $WatchList{$path->{type}}{$path->{content}}{"alert"} = 
      (defined($path->{alert}) && $path->{alert} eq "off") ? 0:1;
    $WatchList{$path->{type}}{$path->{content}}{"type"} =  $path->{type};
    $WatchList{$path->{type}}{$path->{content}}{"syslog"} =
      (defined($path->{syslog}) && $path->{syslog} eq "on") ? 1:0;
    $WatchList{$path->{type}}{$path->{content}}{"filter"} =  $path->{filter};

    our $mask;
    if(!defined($path->{'events'})) {
      $mask = $InotifyEvents{'default'}; 
    }
    else {
      $mask = getMask($path->{'events'});
      $mask = $mask | $InotifyEvents{'create'} if($path->{type} eq "recursive");
    }
    $WatchList{$path->{type}}{$path->{content}}{"mask"} = $mask;
    pathwatch($path->{type},$path->{content});
  }
}

$Mail{From} = $config->{guard}->{email};

Event::loop;

sub getMask {
  my ($events) = @_;
  my $mask = 0;
  foreach my $event ( split(',',$events)) {
    $event =~ s/\s//g;
    warn "Event $event doesn't not exist!" if (!defined($InotifyEvents{$event}));
    $mask = $mask | $InotifyEvents{$event};
  }
  return $mask;
}

sub pathwatch {
  our $mask;
  my ($mode,$path) = @_;
  if(-e "$path") {
    if($mode eq "single") {
      if($VERBOSE>1) {
        print "Watch $path\n";
        syslog("info","Watch $path");
      }
      $inotify->watch ("$path", $mask, \&mywatch);
    }
    elsif($mode eq "recursive") {
      File::Find::find({wanted => \&wanted, "no_chdir" => 1}, "$path");
    }
  }
}

sub wanted {
  our $mask;
  if(-d $File::Find::name) {
  	return if(!defined(getWatchList($File::Find::name)));
  	if($VERBOSE>1) {
       print "Watch $File::Find::name\n";
       syslog("info","Watch $File::Find::name");
  	}
    $inotify->watch ("$File::Find::name", $mask, \&mywatch);
  }
}

sub getWatchList {
  my ($path) = @_;
  return undef if($WatchList{"exception"}{$path});
  foreach my $key (keys %{$WatchList{"exception"}}) {
    return undef if("$path" =~ /^$key/);
  }
  return $WatchList{"single"}{$path} if(defined $WatchList{"single"}{$path});
  return $WatchList{"recursive"}{$path} if(defined $WatchList{"recursive"}{$path});
  foreach my $key (keys %{$WatchList{"recursive"}}) {
    return $WatchList{"recursive"}{$key} if($path =~ /^$key/);
  }
  return undef;
}

sub mywatch {
  my $e = shift;
  my $Message;
  my $localMessage;
  $Filename = $e->fullname;
  return if(defined $WatchList{"exception"}{$Filename});
  my $Path = getWatchList($e->{w}->{name});
  return if(!defined($Path) || 
    (defined $Path->{'filter'} && $e->{name} !~ /$Path->{'filter'}/));
  
  my $now = POSIX::strftime "%e/%b/%Y %H:%M:%S", localtime;
  if($VERBOSE>0) {
  	my $mask = $e->mask;
  	if($e->IN_ISDIR) { $mask = ($e->mask ^ IN_ISDIR); $Message = "IN_ISDIR";}
  	if($e->IN_ONESHOT) { $mask = ($e->mask ^ IN_ONESHOT); $Message = "IN_ONESHOT";}
	$Message = (defined $Message) ? "$Message,$InotifyEventNames{$mask} $Filename" : 
	  "$InotifyEventNames{$mask} $Filename";
  	print "[$now] $Message\n";
	syslog("info","$Message") if($Path->{'syslog'});
  	$Mail{Subject} = "[$PROGRAM] " . hostname() . ": $InotifyEventNames{$mask} $Filename";  
  }

  if($e->IN_CREATE && -d $Filename && $Path->{'type'} eq "recursive") {
    print STDERR "[$now] * Directory $Filename is watched\n" if($VERBOSE>0);
    syslog("info","* Directory $Filename is watched") if($Path->{'syslog'});
    $inotify->watch ($Filename, $Path->{'mask'}, \&mywatch);
  }
  elsif($e->IN_CLOSE_WRITE && -f $Filename) {
  	$localMessage = "* $Filename is closed\n";
    $Message = "$Message\n$localMessage";
    $Mail{Subject} = "[$PROGRAM] " . hostname() . ": $Filename is changed";  
    print STDERR "[$now] $localMessage" if($VERBOSE>0);
    syslog("info","$localMessage") if($Path->{'syslog'});
  }
  elsif($e->IN_DELETE) {
    $localMessage = "* $Filename is deleted\n";
    $Message = "$Message\n$localMessage";
    $Mail{Subject} = "[$PROGRAM] " . hostname() . ": $Filename is deleted";  
    print STDERR "[$now] $localMessage" if($VERBOSE>0);
    syslog("info","$localMessage") if($Path->{'syslog'});
  }
  elsif($e->IN_MOVED_FROM || $e->IN_MOVED_TO) {
    if($e->IN_MOVED_FROM) {
      $Events{$e->{cookie}} = "$Filename";
    }
    elsif($e->IN_MOVED_TO) {
      my $FileMove = $Events{$e->{cookie}} . ":$Filename";
      
      $localMessage = "* $Events{$e->{cookie}} is moved to $Filename\n";
      $Message = "$Message\n$localMessage";
      $Mail{Subject} = "[$PROGRAM] " . hostname() . ": $Events{$e->{cookie}} is moved to $Filename";  
      print STDERR "[$now] $localMessage" if($VERBOSE>0);
      syslog("info","$localMessage") if($Path->{'syslog'});
      undef $Events{$e->{cookie}};
    }
  }
  elsif($e->IN_DELETE_SELF && -f $Filename && defined $WatchList{$Filename}) {
  	
  	$localMessage = "* $Filename is replaced but watched again\n";
    $Message = "$Message\n$localMessage";
    $Mail{Subject} = "[$PROGRAM] " . hostname() . ": $Filename is replaced";  
    print STDERR "[$now] $localMessage" if($VERBOSE>0);
    syslog("info","$localMessage") if($Path->{'syslog'});
    $inotify->watch ("$Filename", $Path->{'mask'}, \&mywatch);
  }
  if(defined($Path->{exec})) {
    my $command = stringf("$Path->{exec}",%formats);
    print STDERR "[$now] * Command: $command\n" if($VERBOSE>0);
    syslog("info","* Command: $command") if($Path->{'syslog'});
    #We have already backslashed the escape characters in $Filename (in %formats).
    $command =~ /^(.+)$/;
    return if(!defined($1));
    my $securecommand = $1;
    system("$securecommand");
  }
  if(defined($Message) && $Path->{'alert'}) {
    $Mail{Message} = "[$now]\n$Message";
    $Mail{To} = $Path->{'contactpoint'};
    print STDERR "[$now] * Send email to $Mail{To}\n" if($VERBOSE>0);
    syslog("info","* Send email to $Mail{To}") if($Path->{'syslog'});
    sendmail(%Mail) or warn $Mail::Sendmail::error;
  }
}

sub stringf() {
  my $ind;
  my ($string,%format) = @_;
  foreach my $key (keys %format) {
    $ind = index $string,"%$key";
    next if($ind == -1);
    substr($string,$ind,2) = &{$format{$key}};
    $string = stringf($string,%format);
  }
  $string;
}

sub Usage {
  VERSION_MESSAGE();
  HELP_MESSAGE();
}

sub VERSION_MESSAGE {
  print "$PROGRAM $VERSION, a realtime filesystem monitor.\n";
  print "Cahya Wirawan <cahya at gmx dot at>, Vienna 2006.\n";
}

sub HELP_MESSAGE {
  print <<ENDOFHELP;
  
In the daemon mode, $PROGRAM has following options:
Usage: iwatch [-d] [-f <config file>] [-v] [-p <pid file>]
  -d Execute the application as daemon.
  -f <config file>
     Specify an alternate xml configuration file.
  -p <pid file>
     Specify an alternate pid file (default: $PIDFILE)
  -v Verbose mode.

And in the command line mode:
Usage: iwatch [-c command] [-e event[,event[,..]]] [-h|--help] [-m <email address>] 
         [-r] [-s <on|off>] [-t <filter string>] [-v] [--version] [-x exception] <target>

  Target is the directory or file you want to monitor.
  -c command
     Specify a command to be executed if an event occurs. And you can use
     following special string format in the command:
       %f Full path of the filename that gets an event.
       %p Program name (iWatch)
       %v Version number
  -e event[,event[,..]]
     Specify a list of events you want to watch. Following are the possible events you can use:
       access        : file was accessed
       modify        : file was modified
       attrib        : file attributes changed
       close_write   : file closed, after being opened in writeable mode
       close_nowrite : file closed, after being opened in read-only mode
       close         : file closed, regardless of read/write mode
       open          : file was opened
       moved_from    : File was moved away from.
       moved_to      : File was moved to.
       move          : a file/dir within watched directory was moved
       create        : a file was created within watched directory
       delete        : a file was deleted within watched directory
       delete_self   : the watched file was deleted
       unmount       : file system on which watched file exists was unmounted
       q_overflow    : Event queued overflowed
       ignored       : File was ignored
       isdir         : event occurred against dir
       oneshot       : only send event once
       all_events    : All events
       default       : close_write, create, delete, move, delete_self and move_self.
  -h, --help
     Print this help.
  -m <email address>
     Specify the contact point's email address.
  -r Recursivity of the watched directory.
  -s <on|off>
     Enable or disable reports to the syslog (default is off/disabled)
  -t <filter string>
     Specify a filter string (regex) to compare with the filename or directory name. 
  -v verbose mode.
  --version
     Print the version number.
  -x exception
     Specify the file or directory which should not be watched.
ENDOFHELP
}
