#! /usr/bin/perl -w

# Copyright (C) 2015-2021 Arne Wichmann <aw@saar.de>
#
# Some parts copied from logtail2
# Copyright (C) 2003 Jonathan Middleton <jjm@ixtab.org.uk
# Copyright (C) 2001 Paul Slootman <paul@debian.org>
# - also GPL version 2 (among others)
#
# This little thing is distributed under the GNU General Public License,
# version 2. If you need the license, ask your distributor for it.

use strict;

use Memory::Usage;
# use Devel::Size qw(total_size);
# use Devel::Leak;
# use Test::LeakTrace;

# my $handle;
# my $leaveCount = 0;
# my $enterCount = Devel::Leak::NoteSV($handle);
# print STDERR "ENTER: $enterCount SVs\n";

use FileHandle;
use IO::Pipe;
use Date::Parse;
use Getopt::Std;
use Config::Simple;
use Fcntl qw(:flock);
use File::Basename;

sub getmergedlog(@);
sub chpat($);
sub onelogline($);
sub closefile($);

# like logcheck, but one additional format:
# * <AlteMarke> <NeueMarke> <timeout> <pattern>
# mark . is empty and will be ignored
# if AlteMarke is valid the pattern is tried, NeueMarke is set and valid
# for timeout (seconds). if AlteMarke begins with ! the pattern is tried
# only if AlteMarke is invalid. if NeueMarke begins with ! the pattern will
# be printed even if it matched

# -f filename	- checks file given
# -F config	- config file (space separated)
use vars qw($opt_f $opt_F);
getopts("f:F:");

my($config)=new Config::Simple(syntax=>'simple');
if (defined($opt_F)) { $config->read($opt_F); }

$config->param('statedir') or $config->param('statedir',"/var/lib/logcheck");
$config->param('configdir') or $config->param('configdir',"/etc/logcheck");
$config->param('logfiles') or 
  $config->set_param('logfiles',["/var/log/syslog","/var/log/auth.log"]);
$config->param('ps') or $config->set_param('ps',["cracking",
  "cracking.ignore","violations","violations.ignore",0,"ignore"]);
$config->param('ignorelevels') or
  $config->set_param('ignorelevels',["paranoid","server","workstation"]);

our($Statedir)=$config->param('statedir');
my(@ps)=$config->param('ps');
our(@ignorelevels)=$config->param('ignorelevels');

$< || die "do not use this as root";

chdir($config->param('configdir')) || 
  die "could not cd to ".$config->param('configdir');

# if [ ! -f /usr/bin/lockfile-create -o \
#      ! -f /usr/bin/lockfile-remove -o \
#      ! -f /usr/bin/lockfile-touch ]; then
#     echo "fatal: lockfile-progs is a prerequisite for logcheck, and was not found."
#     exit 1
# fi

umask 077;


my(%patterns); # hash of lists

our(%marks); # see chpat

# if -f <file> is given, do not use marks file and do not keep state of
# given files. Otherwise read the marks file and make sure that only one
# instance is running.
if (defined($opt_f)) {
    $config->set_param('logfiles',[ $opt_f ]);
    $Statedir=($ENV{"TMPDIR"}||"/tmp")."/logcheck.$$";
    mkdir($Statedir,0700) or die "creating temp dir failed: $!";
} elsif (open(M,"$Statedir/marks")) {
  flock(M,LOCK_EX) or die "Cannot lock mark file - $!\n";
  while (<M>) {
    chomp;
    my($v,$k)=split(" ",$_,2);
    $marks{$k}=$v;
  }
  close(M);
}

# read all patterns into a big pattern-hash
for my $ps (@ps) {
  next unless $ps;
  # This is a bit convoluted because of the mixture of globs and regexes
  # If you have a short and beautiful solution please enlighten me.
  my($rest)=''; # glob
  $rest=".{".join(',',@ignorelevels)."}" if $ps eq 'ignore';
  # if we can read and not execute the open below will fail
  for (glob("./$ps.d$rest")) { -r $_ or warn "Cannot read $_" }
  my @p=grep {!/^#/ and !/^\s*$/ } map {
      open(_,"<",$_) or warn "Cannot open $_: $!";
      <_>;
    } grep { m(\./$ps\.d(\.\w+)?/[a-zA-Z0-9_-]+$) } glob("./$ps.d$rest/*");
  close(_);
  chomp(@p);
  # the following seems to mostly solve the problem mentioned in chpat:
  # I precompile all patterns here - it speeds up later matchings and has
  # the result that mentioned errors waste space in memory only once
  # TODO - check that it works, rethink comments and remove most debugging
  # code
  # TODO - isolate bug and report
  @p=map { substr($_,0,2) eq '* '?$_:qr/$_/} @p;
  $patterns{$ps}=\@p;
}

# memory leak debugging
my ($memusage)=Memory::Usage->new();

# read all files in order of timestamps, check all patterns
my(@logfiles)=$config->param('logfiles');
our(%out);
our($counter)=0; # memory leak debugging
while ($_=getmergedlog(@logfiles)) {
  my($print);
  # my($handle);
  # my $count=Devel::Leak::NoteSV($handle);
  # my(@info)=leaked_info{
  for my $ps (0..($#ps-1)/2) {
    if ($ps[2*$ps]) { for my $p (@{$patterns{$ps[2*$ps]}}) { # alert-liste
      chpat($p) and $print=$ps[2*$ps];
    } } else { $print='normal'; }
    if ($print and $ps[2*$ps+1]) { # ignore-liste
      for my $p (@{$patterns{$ps[2*$ps+1]}}) { chpat($p) and $print=0; } }
    last if $print;
  }
  # };
  # Devel::Leak::CheckSV($handle);

  $out{$print}.=$_ if $print;

  # memory leak debugging
  next if ++$counter%10;
  # next if ++$counter%100;
  # $memusage->record('state '.$counter/10);
  $memusage->record('state '.$counter/100);
  my(@memarr)=$memusage->state();
  my $vsz=$memarr[$#memarr][$#{$memarr[$#memarr]}][2];
  if ($vsz>800000) {
  # if ($vsz>12000000) {
    print STDERR "VSZ $vsz\n";
    print STDERR "Stopping now after $counter lines\n";
    for my $i (@logfiles) { closefile($i) }
    last;
  } elsif ($vsz>600000) { print STDERR "Warning: VSZ $vsz\n"; }
  # } elsif ($vsz>10000000) { print STDERR "Warning: VSZ $vsz\n"; }
}

# my($usememusage)=0;
for my $ps (0..($#ps-1)/2) {
  # This assumes @ps will not be used after this block.
  # $ps[2*$ps] is 0 if a pattern-set directory is set to empty
  $ps[2*$ps]='normal' unless $ps[2*$ps];
  print("Log category ".$ps[2*$ps].":\n\n".$out{$ps[2*$ps]})
    if $out{$ps[2*$ps]};
  # $usememusage=1 if $out{$ps[2*$ps]};
}

# TODO: error handling
open(M,">$Statedir/marks");
for (keys(%marks)) { print M $marks{$_}." $_\n" }
flock(M,LOCK_UN) or die "Cannot unlock mark file - $!\n";
close(M);

if (defined($opt_f)) {
  unlink(glob("$Statedir/*"));
  rmdir($Statedir);
}

# $leaveCount = Devel::Leak::CheckSV($handle);
# print STDERR "\nLEAVE: $leaveCount SVs\n";

# if ($usememusage) {
#   $memusage->record('end');
#   my(@memarr)=$memusage->state();
#  print STDERR $memarr[$#memarr][$#{$memarr[$#memarr]}][2]." last vsz?\n";
#  $memusage->dump();
# }

exit 0;
# subs start here


# TODO: debugfunktionalitaet - mehr verbose (welche regeln schlagen zu)
#   dazu musz ich allerdings chpat "haeszlicher" machen. ich stell das mal
#   zurueck - vielleicht musz ich da eh nochmal dran

# check $_ for $_[0] 
sub chpat($) {
  my($p)=shift;

  # the following create OOM situations. for some reason loads of warnings
  # from original logcheck patterns seem to pile up in uncleanly referenced
  # data structures causing memory bloat. TODO: find a better solution than
  # disabling all regexp warnings - why are these warnings not printed?
  # moreover - disabling warnings does not make the problem go away
  # no warnings;

  return /$p/ unless substr($p,0,2) eq '* ';
  # if pattern starts with * we want to use a mark - see 
  my(undef,$old,$new,$tmout,$pat)=split(' ',$p,5);
  # TODO: is '15' ok here, or should I make this more general?
  my($time)=str2time(substr($_,0,15));
  unless (defined($time)) {
    warn("Invalid time on start of line in $_");
    $time=0;
  }
  my($not)=($old=~s/^!//);
  my($mnm)=($new=~s/^!//); # mark not match
  my($match)=0;
  $match=1 if ($old eq '.');
  if (defined($marks{$old})) { 
    if ($marks{$old}>$time) { $match=1 }
    else { delete($marks{$old})}
  }
  $match=!$match if $not;
  return 0 unless $match and /$pat/;
  unless ($new eq '.') { $marks{$new}=$time+$tmout; }
  return !$mnm;
}

# return the smallest line from a list of logfiles
sub getmergedlog(@) {
  our(@lastlines,$smallest);
  unless (defined($smallest)) {
    for my $i (0..$#_) {
      $lastlines[$i]=onelogline($_[$i]);
      # TODO - what if the a file is specified twice?
      # TODO - weed out logfiles wich are EOF
      # gt is inadequate here as we want to sort by date -- TODO
      if (!defined($smallest) || $lastlines[$smallest] gt $lastlines[$i]) {
        $smallest=$i;
      }
    }
  }

  my($return)=$lastlines[$smallest];
  $lastlines[$smallest]=onelogline($_[$smallest]);
  # TODO - weed out logfiles wich are EOF

  for (0..$#_) {
    # gt is inadequate here as we want to sort by date -- TODO
    $smallest=$_ if $lastlines[$_] and
      (!$lastlines[$smallest] or $lastlines[$_] lt $lastlines[$smallest]);
  }

  return $return;
}

# the following is copied (and reworked) from logtail2
sub print_from_offset($$);
sub inode($);
sub get_directory_contents($);
sub determine_rotated_logfile($$);

our(%handle,%rotated);
# returns one line not read in a previous run from a given logfile - opening
# it if needed, doing magic stuff in case of rotations
# returns undef on EOF
sub onelogline($) {
  my($logfile)=shift; 
  my($reallogfile)=$logfile;

  my($offsetfile)=$logfile;
  $offsetfile=~s(/)(.)g;
  $offsetfile="$Statedir/offset$offsetfile";

  unless ($handle{$logfile}) {
    my ($inode, $ino, $offset) = (0, 0, 0);
    my($size);

    if (open(OFFSET, $offsetfile)) {
      $_ = <OFFSET>;
      if (defined $_) {
	chomp $_;
	$inode = $_;
	$_ = <OFFSET>;
	if (defined $_) {
	  chomp $_;
	  $offset = $_;
	}
      }
    }

    # determine log file inode and size
    unless (($ino,$size) = (stat($logfile))[1,7]) {
	print STDERR "Cannot get $logfile file size: $!\n";
	exit 65;
    }

    if ($inode == $ino) {
	# inode is still the same
	# exit 0 if $offset == $size; # short cut
	if ($offset > $size) {
	    $offset = 0;
	    print "***************\n";
	    print "*** WARNING ***: Log file $logfile is smaller than last time checked!\n";
	    print "*************** This could indicate tampering.\n";
	}
    }

    if ($inode != $ino) {
      # this is the interesting case: inode has changed.
      # So the file might have been rotated. We need to print the
      # entire file.
      # Additionally, we might want to see whether we can find the
      # previous instance of the file and to process it from here.
      #print "inode $inode, ino $ino\n";
      my $rotatedfile = determine_rotated_logfile($logfile,$inode);
      if ( $rotatedfile ) {
        $rotated{$logfile}=1;
	$reallogfile=$rotatedfile;
      } else { $offset=0; }
    }

    unless (open($handle{$logfile},'<', $reallogfile)) {
        print STDERR "File $reallogfile cannot be read: $!\n";
        exit 66;
    }

    seek($handle{$logfile}, $offset, 0) or die;
  }

  # $ret=<$handle{$logfile}> - $handle{$logfile} is treated as a file glob
  my($ret)=$handle{$logfile};
  $ret=<$ret>;

  # this will seldomly be called during the same call as the opening part
  if (not $ret and $rotated{$logfile}) {
      close($handle{$logfile});
      open($handle{$logfile},'<', $logfile) 
        or die "File $logfile cannot be read: $!\n";
      delete($rotated{$logfile});
      $ret=$handle{$logfile};
      defined($ret=<$ret>)
        or warn "unrotated file empty - expect complications";
  }
  if (not $ret and not $rotated{$logfile}) { closefile($logfile) }
  return $ret;
}

sub closefile($) {
  my($logfile)=shift;
  my($size) = tell($handle{$logfile});
  my($offsetfile)=$logfile;
  $offsetfile=~s(/)(.)g;
  $offsetfile="$Statedir/offset$offsetfile";

  unless (open(OFFSET, ">", $offsetfile)) {
    print STDERR "File $offsetfile cannot be created. Check your permissions: $!\n";
    exit 73;
  }
  print OFFSET inode($logfile)."\n$size\n";
  close OFFSET;
  # I am not closing $handle{$logfile} - doing that might aggravate some
  # error cases in getmergedlog
}

sub inode($) {
    my ($filename) = @_;
    my $inode = 0;
    unless (-e $filename && ($inode = ((stat($filename))[1])) ) {
        print STDERR "Cannot get $filename inode: $!\n";
        exit 65;
    }
    return $inode;
}

sub get_directory_contents($) {
    my ($filename) = @_;
    my $dirname = dirname($filename);
    unless (opendir(DIR, $dirname)) {
        print STDERR "Cannot open directory $dirname: $!\n";
        exit 65;
    }
    my @direntries = readdir(DIR);
    closedir DIR;
    return @direntries;
}

sub determine_rotated_logfile($$) {
    my ($filename,$inode) = @_;
    my $rotated_filename;
    # this subroutine tries to guess to where a given log file was
    # rotated. Its magic is mainly taken from logcheck's logoutput()
    # function with dateext magic added.

    #print "determine_rotated_logfile $filename $inode\n";
    for my $codefile (glob("/usr/share/logtail/detectrotate/*.dtr")) {
        my $func = do $codefile;
        if (!$func) {
            print STDERR "cannot compile $codefile: $!";
            exit 68;
        }
        $rotated_filename = $func->($filename);
        last if $rotated_filename;
    }
    #if ($rotated_filename) {
    #  print "rotated_filename $rotated_filename (". inode($rotated_filename). ")\n";
    #} else {
    #  print "no rotated file found\n";
    #}
    if ($rotated_filename && -e "$rotated_filename" && inode($rotated_filename) == $inode) {
      return $rotated_filename;
    } else {
      return "";
    }
}


# # args -f -o
# sub logtail($$) {
#   my($logfile, $offsetfile)=@_;
#   my($size);
# 
#   my ($inode, $ino, $offset) = (0, 0, 0);
# 
#   if ($offsetfile) {
#       # If offset file exists, open and parse it.
#       if (open(OFFSET, $offsetfile)) {
# 	  $_ = <OFFSET>;
# 	  if (defined $_) {
# 	      chomp $_;
# 	      $inode = $_;
# 	      $_ = <OFFSET>;
# 	      if (defined $_) {
# 		  chomp $_;
# 		  $offset = $_;
# 	      }
# 	  }
#       }
# 
#       # determine log file inode and size
#       unless (($ino,$size) = (stat($logfile))[1,7]) {
# 	  print STDERR "Cannot get $logfile file size: $!\n";
# 	  exit 65;
#       }
# 
#       if ($inode == $ino) {
# 	  # inode is still the same
# 	  exit 0 if $offset == $size; # short cut
# 	  if ($offset > $size) {
# 	      $offset = 0;
# 	      print "***************\n";
# 	      print "*** WARNING ***: Log file $logfile is smaller than last time checked!\n";
# 	      print "*************** This could indicate tampering.\n";
# 	  }
#       }
# 
#       if ($inode != $ino) {
# 	  # this is the interesting case: inode has changed.
# 	  # So the file might have been rotated. We need to print the
# 	  # entire file.
# 	  # Additionally, we might want to see whether we can find the
# 	  # previous instance of the file and to process it from here.
# 	  #print "inode $inode, ino $ino\n";
# 	  my $rotatedfile = determine_rotated_logfile($logfile,$inode);
# 	  if ( $rotatedfile ) {
# 	    print_from_offset($rotatedfile,$offset);
# 	  }
# 	  # print the actual file from beginning
# 	  $offset = 0;
#       }
#   }
# 
#   $size = print_from_offset($logfile,$offset);
# 
# # update offset, unless test mode
#     unless (open(OFFSET, ">", $offsetfile)) {
#         print STDERR "File $offsetfile cannot be created. Check your permissions: $!\n";
#         exit 73;
#     }
#     print OFFSET "$ino\n$size\n";
#     close OFFSET;
# }
