#!/usr/local/bin/perl

# */1 * * * * /etc/logwatcher/logwatcher.pl


use Modern::Perl;
use File::ReadBackwards;
use Data::Dumper;
use Proc::ProcessTable;
use Log::Log4perl::CommandLine qw(:all);
use JSON::XS;
use BSON qw/encode decode/;
use DateTime;
use Time::HiRes qw(tv_interval gettimeofday);
use File::Basename;
use Sys::Syslog;
use File::Basename;

# some constants
my $TEST            = 0;
my $PRINT_RESULT    = 1;
my $BINARY_STORAGE  = 0;
my $SAVE_STATE      = dirname(__FILE__).'/save_state.conf';
my $RULES_FILE      = dirname(__FILE__).'/rules.conf';
my $IPTABLES        = '/sbin/iptables';
my $IPTABLES_CHAIN  = 'INPUT';
my $TIMEZONE        = 'Australia/Brisbane';

# For debugging print out lots of info when someone is banned/unbanned
my $PRINT_WHEN_BANNED   = 0;
my $PRINT_WHEN_UNBANNED = 0;

# ip address that cannot be banned
my @allowed_ips;

# the state and rules of this watcher
my %rules;
my %state;
my $logger = Log::Log4perl->get_logger();

# the string result, this becomes an email if used with cron
my $string_result = '';

# the month abbreviations
my %months_abbrev = (
	'Jan' => 1,
	'Feb' => 2,
	'Mar' => 3,
	'Apr' => 4,
	'May' => 5,
	'Jun' => 6,
	'Jul' => 7,
	'Aug' => 8,
	'Sep' => 9,
	'Oct' => 10,
	'Nov' => 11,
	'Dec' => 12,
);

# get the memory useage of this script
sub memory_usage() {
    my $t = new Proc::ProcessTable;
    foreach my $got (@{$t->table}) {
        next unless $got->pid eq $$;
        return $got->size;
    }
}

# load the runes from the rules file
sub load_rules {
	open (MYFILE, '<'.$RULES_FILE) or return;
	if ($BINARY_STORAGE) {
		%rules = %{decode(join('', <MYFILE>))};
	}
	else {
		%rules = %{decode_json(join('', <MYFILE>))};
	}
	close (MYFILE);

	if (defined($rules{'allowed_ips'})) {
		@allowed_ips = @{$rules{'allowed_ips'}};
		delete($rules{'allowed_ips'});
	}

	if (defined($rules{'iptables_chain'})) {
		$IPTABLES_CHAIN = $rules{'iptables_chain'};
		delete($rules{'iptables_chain'});
	}
}

# init the state of the log watcher
sub init_state {
	# allowed ip addresses
	for(my $i=0; $i<scalar(@allowed_ips); $i++) {
		$allowed_ips[$i] = ip_to_int($allowed_ips[$i]);
	}

	# the banned ip addresses
	$state{'banned'} = {};
	$state{'currently_banned'} = {};
	$state{'unban'} = {};

	$state{'rule_ids'} = {};
	$state{'file_ids'} = {};

	$state{'ip_watch'} = {};
	$state{'last_lines'} = {};

	# saved state
	my $unique_rule_id = 1;
	my $unique_file_id = 1;
	if (-f $SAVE_STATE) {
		open (MYFILE, '<'.$SAVE_STATE) or return;
		if ($BINARY_STORAGE) {
			%state = %{decode(join('', <MYFILE>))};
		}
		else {
			%state = %{decode_json(join('', <MYFILE>))};
		}
		close (MYFILE);

		# find the max unique id
		foreach my $filename (keys %rules) {
			foreach my $rule (@{$rules{$filename}}) {
				if (defined($state{'rule_ids'}) &&
					defined($state{'rule_ids'}->{$filename}) &&
					defined($state{'rule_ids'}->{$filename}->{$rule->{'name'}})) {
					if ($state{'rule_ids'}->{$filename}->{$rule->{'name'}} >= $unique_rule_id) {
						$unique_rule_id = $state{'rule_ids'}->{$filename}->{$rule->{'name'}} + 1;
					}
				}
			}

			if (defined($state{'file_ids'}) && defined($state{'file_ids'}->{$filename})) {
				if ($state{'file_ids'}->{$filename} >= $unique_file_id) {
					$unique_file_id = $state{'rule_ids'}->{$filename} + 1;
				}
			}
		}
	}

	# make sure everything has an id
	foreach my $globname (keys %rules) {
		my $rule_key = $globname;
		if ($TEST) {
			my($filename, $directories, $suffix) = fileparse($globname);
			$globname = $filename;
		}

		my @files;
		if ($globname =~ /\*/) {
			eval("\@files = <$globname>;");
		}
		else {
			@files = ($globname);
		}
		foreach my $filename (@files) {
			$state{'file_ids'}->{$filename} = $unique_file_id++ unless defined $state{'file_ids'}->{$filename};
			$state{'rule_ids'}->{$filename} = {} unless defined $state{'rule_ids'}->{$filename};
			foreach my $rule (@{$rules{$rule_key}}) {
				unless (defined($state{'rule_ids'}) &&
						defined($state{'rule_ids'}->{$filename}) &&
						defined($state{'rule_ids'}->{$filename}->{$rule->{'name'}})) {
					die "No name for rule: ".Dumper($rule) unless defined $rule->{'name'};
					$state{'rule_ids'}->{$filename}->{$rule->{'name'}} = $unique_rule_id++;
				}

				$rule->{'id'} = $state{'rule_ids'}->{$filename}->{$rule->{'name'}};
			}

			unless (defined($state{'file_ids'}) && defined($state{'file_ids'}->{$filename})) {
				$state{'file_ids'}->{$filename} = $unique_rule_id++;
			}
		}
	}

	# clear last run history
	$state{'banned_this_time'} = {};
}

# save the current state of the system
sub save_state {
	open (MYFILE, '>'.$SAVE_STATE) or die "Could not open save state file!";
	if ($BINARY_STORAGE) {
		print MYFILE encode(\%state);
	}
	else {
		print MYFILE encode_json(\%state);
	}
	close (MYFILE);
}

# convert an ip address to an integer
sub ip_to_int {
	my $ip = shift;
	if ($ip =~ /(\d+)\.(\d+)\.(\d+)\.(\d+)/) {
		return (int($1) << 24) | (int($2) << 16) | (int($3) << 8) | int($4);
	}
	return undef;
}

# convert an integer to an ip address
sub int_to_ip {
	my $ip = shift;

	my $string = '';
	$string .= int(((0xFF << 24) & $ip) >> 24) . ".";
	$string .= int(((0xFF << 16) & $ip) >> 16) . ".";
	$string .= int(((0xFF << 8) & $ip) >> 8) . ".";
	$string .= int(0xFF & $ip);

	return $string;
}

# Parse a syslog date of the format: Dec 16 21:23:39
sub parse_date_syslog {
	my $string = shift;

	if ($string =~ /([A-Za-z]{3}) (\d+) (\d+):(\d+):(\d+)/) {
		my $month = $months_abbrev{$1} or continue;
		my $day   = $2;
		my $hour  = $3;
		my $min   = $4;
		my $sec   = $5;

		my $now  = DateTime->now(time_zone  => $TIMEZONE)->epoch;
		my $date = DateTime->new(
			year       => DateTime->now->year,
			month      => $month,
			day        => $day,
			hour       => $hour,
			minute     => $min,
			second     => $sec,
			time_zone  => $TIMEZONE,
		);
		if ($date->epoch > $now) {
			$date = DateTime->new(
				year       => DateTime->now->year-1,
				month      => $month,
				day        => $day,
				hour       => $hour,
				minute     => $min,
				second     => $sec,
				time_zone  => $TIMEZONE,
			);
		}
		return $date;
	}

	$logger->warn("unparseable syslog date: $string");
	return undef;
}

# parse a date from the httpd daemon
sub parse_date_httpd {
	my $string = shift;

	if ($string =~ qr|(\d+)/([A-Za-z]{3})/(\d+):(\d+):(\d+):(\d+)|) {
		my $month = $months_abbrev{$2} or continue;
		my $day   = $1;
		my $year  = $3;
		my $hour  = $4;
		my $min   = $5;
		my $sec   = $6;

		return DateTime->new(
			year       => $year,
			month      => $month,
			day        => $day,
			hour       => $hour,
			minute     => $min,
			second     => $sec,
			time_zone  => $TIMEZONE,
		);
	}

	$logger->warn("unparseable httpd date: $string");
	return undef;
}

# parse a date
sub parse_date {
	my ($format, $string) = @_;

	# TODO validate format
	if ($format eq 'syslog') {
		return parse_date_syslog($string);
	}
	elsif ($format eq 'epoch') {
		return DateTime->from_epoch(epoch => int($string), time_zone  => $TIMEZONE);
	}
	elsif ($format eq 'httpd') {
		return parse_date_httpd($string);
	}
	else {
		$logger->warn("Invalid date format: $format");
		return undef;
	}

	return eval("parse_date_$format(\$string);");
}

# use iptables to ban an ip address
sub iptables_ban {
	my $ip = int_to_ip(shift);

	my $cmd = "$IPTABLES -A $IPTABLES_CHAIN -s $ip -j DROP";
	system $cmd;

	# print the iptables info
	if ($PRINT_WHEN_BANNED) {
		print "$cmd\n\n";
		print "Successfully banned ".$ip."\n\n";
		system("$IPTABLES -L -v");
	}

	return 1;
}

# use iptables to unban an ip address
sub iptables_unban {
	my $ip = int_to_ip(shift);

	my $cmd = "$IPTABLES -D $IPTABLES_CHAIN -s $ip -j DROP";
	system $cmd;

	# print the iptables info
	if ($PRINT_WHEN_UNBANNED) {
		print "$cmd\n\n";
		print "Successfully unbanned ".$ip."\n\n";
		system("$IPTABLES -L -v");
	}

	return 1;
}

# ban an ip address from the server
sub ban_ip {
	my ($ip, $time, $name, $message) = @_;

	# if they are already banned then don't action
	if (scalar(@allowed_ips)) {
		foreach my $allowed (@allowed_ips) {
			if ($ip == $allowed) {
				$logger->info("Not banning allowed ip: ".int_to_ip($ip));
				return 1;
			}
		}
	}

	# has the person been banned before
	if ($state{'banned_this_time'}->{$ip}) {
		$logger->debug(int_to_ip($ip)." banned this iteration, will not ban again");
		return 1;
	}
	elsif (defined($state{'banned'}->{$ip})) {
		$logger->warn("IP: ".int_to_ip($ip)." is banned, but still attacking!");
	}

	# Increment the counter
	$state{'banned_this_time'}->{$ip} = 1;
	$state{'banned'}->{$ip} = 0 unless defined $state{'banned'}->{$ip};
	$state{'banned'}->{$ip}++;

	# set the unban time
	$state{'currently_banned'}->{$ip} = time();
	$state{'unban'}->{$ip} = time() + $time;

	# ban the person
	if ($TEST) {
		$logger->info("Test Mode not banning");
	}
	elsif (iptables_ban($ip)) {
		$logger->debug("Successfully banned ".int_to_ip($ip));
	}
	else {
		$logger->error("Could not ban ".int_to_ip($ip));
		return 0;
	}

	$string_result .= "Banned ".int_to_ip($ip)." for $time: $name\n\t\t$message\n\n";
	$logger->info("Banned ".int_to_ip($ip)." for $time: $name");
	syslog("info", "Banned ".int_to_ip($ip)." for $time: $name");
	return 1;
}

# action needs to be taken, so do it
sub take_action {
	my ($date, $ip, $message, $rule) = @_;

	$logger->debug("Taking action: $rule->{'action'} ".int_to_ip($ip));

	if ($rule->{'action'} eq 'ban') {
		$logger->error("No ban time specified") unless defined $rule->{'ban_time'};
		ban_ip($ip, $rule->{'ban_time'}, $rule->{'name'}, $message);
		return 1;
	}
	else {
		$logger->error("Invalid action: ".Dumper($rule));
		return 0;
	}
}

# check a log file, stopping at where we last saw it
sub check_backwards {
	my $filename = shift;
	my $globname = shift;
	my $rules = $rules{$globname};
	$logger->info("Checking file $filename");

	# start reading the file backwards
	my $file = File::ReadBackwards->new($filename);
	unless ($file) {
		$logger->error("Could not open file: $filename: $@");
		return 0;
	}
	my $first = 1;
	my $last_line = $state{'last_lines'}->{$filename};
	my $line;
	while( defined( $line = $file->readline ) ) {
		if ($first) {
			$first = 0;
			$state{'last_lines'}->{$filename} = $line;
		}
		if (defined($last_line)) {
			if ($line eq $last_line) {
				$logger->debug("Found last line, stopping processing $filename");
				last;
			}
		}

		# loop thought all the rules
		foreach my $rule (@$rules) {
			# check the rule
			if ($line =~ qr/$rule->{'rule'}/) {
				# get the data
				my ($date, $ip, $message);
				my $name = $rule->{'name'};
				if ($rule->{'format'} eq "date_ip_message") {
					$date    = $1;
					$ip      = ip_to_int($2);
					$message = $3;
				}
				elsif ($rule->{'format'} eq "date_message_ip") {
					$date    = $1;
					$message = $2;
					$ip      = ip_to_int($3);
				}
				elsif ($rule->{'format'} eq "ip_date_message") {
					$ip      = ip_to_int($1);
					$date    = $2;
					$message = $3;
				}
				else {
					$logger->error("Invalid format found! ".Dumper($rule));
					next;
				}
				#$logger->info("-$date- [$ip] |$message|");

				# get the date
				$date = parse_date($rule->{'date_format'}, $date) or next;

				# increment the counter
				my $rule_id = $state{'rule_ids'}->{$filename}->{$rule->{'name'}}
				or die "NO RULE ID: ".$filename . "\n". Dumper($rule) . Dumper(\%state).Dumper(\%rules);
				$state{'ip_watch'}->{$date->ymd}->{$rule_id}->{$ip} = 0
					unless defined $state{'ip_watch'}->{$date->ymd}->{$rule_id}->{$ip};
				$state{'ip_watch'}->{$date->ymd}->{$rule_id}->{$ip}++;

				# check the threshold  FIX ME DateTime->now->ymd
				my $test_date = DateTime->now(time_zone  => $TIMEZONE)->ymd;
				if ($TEST) {
					$test_date = $date->ymd;
				}

				if (defined($state{'ip_watch'}->{$test_date}->{$rule_id}->{$ip}) &&
					$state{'ip_watch'}->{$test_date}->{$rule_id}->{$ip} > $rule->{'threshold'}) {
					take_action($date, $ip, $message, $rule);
				}
			}
		}
    }
}

# unban people who's time is up
sub check_unban {
	my $now = time();
	foreach my $ip (keys %{$state{'unban'}}) {
		if ($TEST || $state{'unban'}->{$ip} < $now) {
			$logger->info("unbanning ".int_to_ip($ip));
			syslog("info", "Unbanning ".int_to_ip($ip));

			# unban the person
			if ($TEST) {
				$logger->info("Test mode, not unbanning");
				delete $state{'currently_banned'}->{$ip};
				delete $state{'unban'}->{$ip};
			}
			elsif (iptables_unban($ip)) {
				$string_result .= "Unbanned ".int_to_ip($ip)."\n";
				delete $state{'currently_banned'}->{$ip};
				delete $state{'unban'}->{$ip};
			}
			else {
				$logger->error("Could not unban ".int_to_ip($ip));
			}
		}
	}
}

# clear out old data
sub clear_old_data {
	foreach my $date (keys %{$state{'ip_watch'}}) {
		if ($date =~ /(\d+)\-(\d+)\-(\d+)/) {
			my $date_obj = DateTime->new(
				year       => $1,
				month      => $2,
				day        => $3,
				time_zone  => $TIMEZONE,
			);

			if ($date_obj->ymd ne DateTime->now->ymd) {
				delete $state{'ip_watch'}->{$date};
			}
		}
	}
}


####################################################################################
# get some inital data
my @start_time = gettimeofday;
my $start_mem  = memory_usage()/1024/1024;
$logger->info('Starting ... Memory usage: ', $start_mem, " MB");

# open the syslog
openlog('logwatcher', "ndelay,pid", "local0");

# check the log files
load_rules();
init_state();
foreach my $globname (keys %rules) {
	my $rule_key = $globname;
	if ($TEST) {
		my($filename, $directories, $suffix) = fileparse($globname);
		$globname = $filename;
	}
	my @files;
	if ($globname =~ /\*/) {
		eval("\@files = <$globname>;");
		foreach my $file (@files) {
			check_backwards($file, $rule_key);
		}
	}
	else {
		check_backwards($globname, $rule_key);
	}
}
check_unban();
clear_old_data();

# save the state of the system
unless ($TEST) {
	$state{'last_run'} = DateTime->now(time_zone  => $TIMEZONE)->ymd." "
						.DateTime->now(time_zone  => $TIMEZONE)->hms;
	save_state();
}

#print Dumper(\%state);
#print Dumper(\%rules);

# log some info
$logger->info('Finished ... Time Taken: ', tv_interval(\@start_time), " secs");
$logger->info('Finished ... Memory usage: ', memory_usage()/1024/1024, " MB");

# print out the result, this is an email if used with cron
if ($PRINT_RESULT) {
	print $string_result;
}
