#!/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('', ))}; } else { %rules = %{decode_json(join('', ))}; } 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))}; } else { %state = %{decode_json(join('', ))}; } 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; }