#!/usr/bin/perl -w
# Originally written 15 February 1999 by Jim Lippard as short hack script.
# Rewritten 25 February 1999 by Jim Lippard to use config file and be
#    a bit fancier.  Early a.m. 26 February: changed some error messages,
#    fixed uninitialized variable problem in read_size_file.
# Modified 11 August 1999 by Jim Lippard to support cyclog-format logs.
#    A cyclog is a directory containing files named with timestamps.
#    For cyclogs, we store the time logs were last examined as well as
#    the size of the last log file examined.  We can only detect a
#    reduction in size (editing) on the last log file examined.
#    cyclog is part of Daniel Bernstein's daemontools package, and may
#    be found at ftp://koobera.math.uic.edu/www/daemontools.html
#    This script now requires Time::HiRes from CPAN.
# Modified 26 August 1999 by Jim Lippard to support multilog-format
#    logs.  This is Bernstein's new replacement for cyclog, the format
#    is very similar.  The only real change is when converting the
#    timestamps.  Also modified to sort files within cyclogs/multilogs.
# Modified 23 December 1999 by Jim Lippard to change name on email to
#    "Reporter" and use hostname minus domain name in subject line.
# Modified 23 April 2002 by Jim Lippard to correctly parse regexps which
#    contain colons.
# Modified 12 February 2003 by Jim Lippard to allow the use of a single
#    reportnew.conf file for multiple hosts in a backwards-compatible
#    way by adding optional begin-host: hostname and end-host: hostname
#    fields.  If master_notify appears outside of any begin-host/end-host
#    blocks, it is the master_notify for all hosts.
# Modified 28 June 2003 by Jim Lippard--there appears to be a bug where
#    sometimes notifications are sent when the value in the notify hash
#    is undefined.  A workaround has been put in place to use the master_notify
#    address when an undefined value is sent to the send_notify sub.
# Modified 12 January 2009 by Jim Lippard to convert djbdns log IP addresses.
# Modified 11 February 2011 by Jim Lippard to change size for warning about
#    logfile turning over.
# Modified 3 December 2011 by Jim Lippard to catch an error condition
#    that leads to an unitialized value for $notify_list in the "To"
#    field generation of send_notify, probably caused by a bug in
#    parse_config that leaves everything undefined (perhaps when a
#    new log is added to the config file, perhaps when it's the first
#    log after a begin-host directive?).
# Modified 25 December 2011 by Jim Lippard to use /etc/reportnew.conf as
#    default config file and put default size file in same dir
#    or get it from the config file. Fixed bug mentioned in previous
#    (used "return" instead of "last" to prematurely exit from
#    parse_config subroutine).

# Suggested enhancements:
# * Allow to run continuously (like swatch) monitoring logs with
#   select.  That will perhaps be more efficient than starting up
#   a perl process every N minutes, and will catch log changes more
#   rapidly.  It should wait a little bit for additional matching
#   messages, though, so that it doesn't send a separate message for
#   each log line.  (Easiest way might be to make it go into an
#   infinite loop, sleeping every N minutes at the end.  Though
#   it would be more efficient to use select.)
# * Allow it to execute a program for matching lines, which can
#   do parsing on the log lines, perform some action, etc.

### Required packages.

use strict;
use Time::HiRes qw( gettimeofday );

### Constants.

my $HOSTNAME = `hostname`;
chop ($HOSTNAME);
my $DOMAINNAME = 'discord.org';
my $SECURITY_ADMIN = 'lippard@discord.org';

my $ECHO = '/bin/echo';

my $SENDMAIL = '/usr/sbin/sendmail';

my $TAI64NLOCAL = '/usr/local/bin/tai64nlocal';

my $VERSION = 'reportnew 1.9a of 25 December 2011';

my $DEFAULT_CONFIG_DIR = '/etc/reportnew';
my $DEFAULT_CONFIG_NAME = 'reportnew.conf';
my $DEFAULT_SIZE_FILE_DIR = '/etc/reportnew';
my $DEFAULT_SIZE_FILE_NAME = 'reportnew.size';
my $CONFIG_SUFFIX = ".conf";
my $SIZE_SUFFIX = ".size";

### Variables.

# Filenames.
use vars qw($config_file $size_file);

# Global variables from config file.
use vars qw(
	    $master_notify
	    @logfiles
	    %match_hash
	    %exclude_hash
	    %notify_hash
	    );

# Global variables from size file.
use vars qw(
	    %log_size
	    %log_mtime
	    %log_checktime
	    );

# Other global variables.
use vars qw(
	    $debug_mode
	    @notify_lines
	    );

# Local variables in main program.
my ($logfile, $size, $mtime, @cyclog_files, $cyclog_file,
    $old_log_checktime, $got_first_cyclog_file);

### Main program.

$debug_mode = 0;

if ($#ARGV == 0) {
    $config_file = $ARGV[0];
}
elsif ($#ARGV < 0) {
    $config_file = "$DEFAULT_CONFIG_DIR/$DEFAULT_CONFIG_NAME";
}
else {
    die "Usage: reportnew [config-file]\n";
}

if (substr ($config_file, length ($config_file) - 5, 5) ne $CONFIG_SUFFIX) {
    $config_file .= $CONFIG_SUFFIX;
}

&parse_config ($config_file);

&read_size_file ($size_file);

foreach $logfile (@logfiles) {
    undef @notify_lines;
    if (!-d $logfile) { # Standard syslog file.
	($log_size{$logfile}, $log_mtime{$logfile}, $log_checktime{$logfile}) =
	    &check_logfile ($logfile, $log_size{$logfile}, $log_mtime{$logfile},
			    $log_checktime{$logfile}, $match_hash{$logfile},
			    $exclude_hash{$logfile}, 0);
    }
    else { # cyclog or multilog
	if (opendir (CYCLOG, $logfile)) {
	    @cyclog_files = grep (!/^\./, readdir (CYCLOG));
	    closedir (CYCLOG);
	    $got_first_cyclog_file = 0;
	    $old_log_checktime = $log_checktime{$logfile};
	    foreach $cyclog_file (sort (@cyclog_files)) {
		next if ($cyclog_file eq 'lock'); # multilog format
		next if ($cyclog_file eq 'state'); # multilog format
		$cyclog_file = $logfile . '/' . $cyclog_file;
		($size, $mtime) = &read_size ($cyclog_file);
		next if ($mtime < $old_log_checktime);
		# If we get here, then we've found the oldest changed file
		# (since we last checked).
		$got_first_cyclog_file++;
		if ($got_first_cyclog_file == 1) {
		    ($log_size{$logfile}, $log_mtime{$logfile}, $log_checktime{$logfile}) =
			&check_logfile ($cyclog_file, $log_size{$logfile}, $log_mtime{$logfile},
					$old_log_checktime, $match_hash{$logfile},
					$exclude_hash{$logfile}, 1);
		}
		# For all the new files, we don't care about size or mtime.
		else {
		    ($log_size{$logfile}, $log_mtime{$logfile}, $log_checktime{$logfile}) =
			&check_logfile ($cyclog_file, 0, 0, 0,
					$match_hash{$logfile}, $exclude_hash{$logfile}, 1);
		}
	    } # foreach
	} # if opendir successful
	else {
	    push (@notify_lines, "Could not open cyclog/multilog $logfile. $!");
	}
    }
    if (defined (@notify_lines)) {
	&send_notify ($HOSTNAME, $logfile, $notify_hash{$logfile}, @notify_lines);
    }
}

&write_size_file ($size_file);

### Subroutines.

# Subroutine to parse configuration file.
sub parse_config {
    my ($config_file) = @_;
    my ($directive, $value, $line_num, $current_logfile,
	$specified_host, $current_host, $all_host_master_notify);

    $line_num = 0;
    $specified_host = 0;
    if (open (CONFIG, $config_file)) {
	while (<CONFIG>) {
	    $line_num++;
	    if (!/^#|^$/) {
		chop;
		($directive, $value) = split (/:\s*/, $_, 2);
		if ($directive eq 'begin-host') {
		    if (defined ($master_notify) && !defined ($current_host)) {
			$all_host_master_notify = $master_notify;
		    }
		    $current_host = $value;
		}
		elsif ($directive eq 'end-host') {
		    if ($current_host ne $value) {
			&send_error ($config_file, "end-host directive does not match begin-host directive (which uses \"$current_host\") on line $line_num. $value\n");
			exit;
		    }
		    if ($current_host eq $HOSTNAME) {
			last;
		    }
		    $current_host = "";
		    undef $master_notify;
		    undef @logfiles;
		    undef %match_hash;
		    undef %exclude_hash;
		    undef %notify_hash;
		}
		elsif ($directive eq 'master_notify') {
		    if (defined ($master_notify)) {
			&send_error ($config_file, "Second master_notify directive on line $line_num. $value");
			exit;
		    }
		    $master_notify = $value;
		}
		elsif ($directive eq 'size_file') {
		    if (defined ($size_file)) {
			&send_error ($config_file, "Second size_file directive on line $line_num. $value");
			exit;
		    }
		    $size_file = $value;
		}
		elsif ($directive eq 'log') {
		    if ((!defined ($current_host) || $current_host eq $HOSTNAME) && !-e $value) {
			&send_error ($config_file, "Logfile does not exist in log directive on line $line_num.  $value");
			exit;
		    }
		    if (grep ($_ eq $value, @logfiles)) {
			&send_error ($config_file, "Previously defined logfile on line $line_num. $value");
			exit;
		    }
		    push (@logfiles, $value);
		    $current_logfile = $value;
		}
		elsif ($directive eq 'match') {
		    if (!defined ($current_logfile)) {
			&send_error ($config_file, "No log directive corresponding to match directive on line $line_num. $_");
			exit;
		    }
		    if (defined ($match_hash{$current_logfile})) {
			&send_error ($config_file, "Too many match directives at line $line_num. $_");
			exit;
		    }
		    if (($value ne 'all') && !&valid_regexp ($value)) {
			&send_error ($config_file, "Invalid match directive on line $line_num. $_");
			exit;
		    }
		    $match_hash{$current_logfile} = $value;
		}
		elsif ($directive eq 'exclude') {
		    if (!defined ($current_logfile)) {
			&send_error ($config_file, "No log directive corresponding to exclude directive on line $line_num. $_");
			exit;
		    }
		    if (defined ($exclude_hash{$current_logfile})) {
			&send_error ($config_file, "Too many exclude directives at line $line_num. $_");
			exit;
		    }
		    if (($value ne 'none') && !&valid_regexp ($value)) {
			&send_error ($config_file, "Invalid exclude directive on line $line_num. $_");
		    }
		    $exclude_hash{$current_logfile} = $value;
		}
		elsif ($directive eq 'notify') {
		    if (!defined ($current_logfile)) {
			&send_error ($config_file, "No log directive corresponding to notify directive on line $line_num. $_");
			exit;
		    }
		    if (defined ($notify_hash{$current_logfile})) {
			&send_error ($config_file, "Too many notify directives at line $line_num.");
			exit;
		    }
		    $notify_hash{$current_logfile} = $value;
		}
		else {
		    &send_error ($config_file, "Unknown directive on line $line_num. $_");
		    exit;
		}
	    }
	}
	close (CONFIG);
    }
    else {
	die "Cannot open config file $config_file. $!\n";
	&send_error ($config_file, "Cannot open config file. $!");
	exit;
    }

    if (!defined ($size_file)) {
	$size_file = "$DEFAULT_SIZE_FILE_DIR/$DEFAULT_SIZE_FILE_NAME";
    }
    elsif (substr ($size_file, length ($size_file) - 5, 5) ne $SIZE_SUFFIX) {
	$size_file .= $SIZE_SUFFIX;
    }

    if (!defined ($master_notify)) {
	if (defined ($all_host_master_notify)) {
	    $master_notify = $all_host_master_notify;
	}
	else {
	    $master_notify = $SECURITY_ADMIN;
	}
    }

    foreach $current_logfile (@logfiles) {
	if (!defined ($match_hash{$current_logfile})) {
	    $match_hash{$current_logfile} = 'all';
	}
	if (!defined ($exclude_hash{$current_logfile})) {
	    $exclude_hash{$current_logfile} = 'none';
	}
	if (!defined ($notify_hash{$current_logfile})) {
	    $notify_hash{$current_logfile} = $master_notify;
	}
    }
}

# Look through a log file for any changed lines; add them to
# the global variable @notify_lines.
sub check_logfile {
    my ($logfile, $old_size, $old_mtime, $old_checktime, $match, $exclude, $cyclog) = @_;
    my ($size, $mtime, $checktime, $date, $line);

    ($size, $mtime) = &read_size ($logfile);
    
    # If the log has changed, gotten smaller, but isn't a new log file,
    # then that's something we need to complain about.  Unfortunately,
    # we don't have a way to look at file creation time.  Do we?
    # If the shrinkage is down to 4096 bytes or less, we assume it was
    # a log rotation.
    if (($mtime > $old_mtime) && ($size < $old_size) && ($size > 4096)) {
	$old_size = 0;
	push (@notify_lines, "Logfile has shrunk in size, but is larger than 4096 bytes ($size bytes).");
	$checktime = gettimeofday();
	return ($size, $mtime, $checktime);
    }

    # Logfile has grown.
    if ($size > $old_size) {
	if (open (LOG, $logfile)) {
	    seek (LOG, $old_size, 0);
	    while (<LOG>) {
		chop;
		if ((($match eq 'all') || (&match_line ($match, $_))) &&
		    (($exclude eq 'none') || (!&match_line ($exclude, $_)))) {
		    if ($cyclog) { # cyclog or multilog
			($date, $line) = split (/\s+/, $_, 2);
			if ((substr ($date, 0, 1) eq '@') && (length ($date) == 15)) { # cyclog
			    $date = localtime ($date);
			    push (@notify_lines, $date . ' ' . $line);
			}
			elsif ((substr ($date, 0, 1) eq '@') && (length ($date) == 25) && (-e$TAI64NLOCAL)) { # multilog
			    open (TAICONVERT, "$ECHO \"$date\" | $TAI64NLOCAL|");
			    $date = <TAICONVERT>;
			    close (TAICONVERT);
			    chop ($date);
			    $line =~ s/\b([a-f0-9]{8})\b/join(".", unpack("C*", pack("H8", $1)))/eg;
			    push (@notify_lines, $date . ' ' . $line);
			}
			else { # unknown, leave it alone
			    push (@notify_lines, $_);
			}
		    }
		    else { # syslog
			push (@notify_lines, $_);
		    }
		}
	    }
	    close (LOG);
	    $checktime = gettimeofday();
	    return ($size, $mtime, $checktime);
	}
	else {
	    push (@notify_lines, "Cannot open log file. $!");
	}
    }
    $checktime = gettimeofday();
    return ($size, $mtime, $checktime);
}

# Return 1 if a regexp is valid, 0 if not.
sub valid_regexp {
    my ($regexp) = @_;

    if (($regexp =~ /\/.*\//) ||
	($regexp =~ /!\/.*\//)) {
	return 1;
    }
    else {
	return 0;
    }
}

# Read the contents of the size file.
sub read_size_file {
    my ($size_file) = @_;
    my ($logfile, $size, $mtime, $checktime);

    if (!-e $size_file) {
	foreach $logfile (@logfiles) {
	    $log_size{$logfile} = 0;
	    $log_mtime{$logfile} = 0;
	    $log_checktime{$logfile} = 0;
	}
    }
    else {
	if (open (SIZEFILE, $size_file)) {
	    while (<SIZEFILE>) {
		chop;
		($logfile, $size, $mtime, $checktime) = split (/,/);
		if (!grep ($_ eq $logfile, @logfiles)) {
		    &send_notify ($HOSTNAME, $config_file, $master_notify, "Logfile $logfile in size file is not in config file.");
		}
		$log_size{$logfile} = $size;
		$log_mtime{$logfile} = $mtime;
		$log_checktime{$logfile} = $checktime;
	    }
	    close (SIZEFILE);
	}
	else {
	    &send_error ($size_file, "Cannot open size file. $!");
	    exit;
	}

	foreach $logfile (@logfiles) {
	    if (!defined ($log_size{$logfile})) {
		&send_notify ($HOSTNAME, $size_file, $master_notify, "Logfile $logfile in config file is not in size file.");
		$log_size{$logfile} = 0;
		$log_mtime{$logfile} = 0;
		$log_checktime{$logfile} = 0;
	    }
	}
    }
}

# Read current log file size and mtime.
sub read_size {
    my ($logfile) = @_;
    my ($dev, $ino, $mode, $nlink, $uid, $gid, $rdev, $size, $atime,
	$mtime, $ctime, $blksize, $blocks);

    ($dev, $ino, $mode, $nlink, $uid, $gid, $rdev, $size, $atime,
     $mtime, $ctime, $blksize, $blocks) = stat $logfile;

    $size = 0 if (!defined ($size));
    $mtime = 0 if (!defined ($mtime));

    return ($size, $mtime);
}

# Return 1 if line matches regexp, 0 if not.
sub match_line {
    my ($regexp, $line) = @_;
    my ($negative);

    if (substr ($regexp, 0, 1) eq '!') {
	$negative = 1;
	$regexp =~ s/^!//;
    }
    else {
	$negative = 0;
    }

    $regexp =~ s/^\///;
    $regexp =~ s/\/$//;

    if (($negative && ($line =~ !/$regexp/)) ||
	(!$negative && ($line =~ /$regexp/))) {
	return 1;
    }
    else {
	return 0;
    }
}

# Send a mail notification.
sub send_notify {
    my ($hostname, $logfile, $notify_list, @lines) = @_;
    my ($line);

    $hostname =~ s/\.$DOMAINNAME$//;

    if (!defined ($notify_list)) {
	if (!defined ($master_notify)) {
	    # This seems to happen periodically, indicating a bug
	    # in the config parsing.
	    $notify_list = $SECURITY_ADMIN;
	}
	else {
	    $notify_list = $master_notify;
	}
    }

    if ($debug_mode) {
	print "$hostname: $logfile ($notify_list)\n";
	foreach $line (@lines) {
	    print "$line\n";
	}
    }

    else {
	open (MAIL, "|$SENDMAIL -t");
	print MAIL "From: Reporter <nobody\@$HOSTNAME>\n";
	print MAIL "To: $notify_list\n";
	print MAIL "Subject: $hostname $logfile\n\n";
	foreach $line (@lines) {
	    print MAIL "$line\n";
	}
	close (MAIL);
    }
}

# Send error notification.
sub send_error {
    my ($filename, $error) = @_;

    if (defined ($master_notify)) {
	&send_notify ($HOSTNAME, $filename, $master_notify, $error);
    }
    else {
	&send_notify ($HOSTNAME, $filename, $SECURITY_ADMIN, $error);
    }
}

# Write out size file.
sub write_size_file {
    my ($size_file) = @_;
    my ($logfile);

    if (open (SIZEFILE, ">$size_file")) {
	foreach $logfile (@logfiles) {
	    print SIZEFILE "$logfile,$log_size{$logfile},$log_mtime{$logfile},$log_checktime{$logfile}\n";
	}
	close (SIZEFILE);
    }
    else {
	&send_notify ($HOSTNAME, $size_file, $master_notify, "Cannot open size file for writing. $!");
    }
}
