#!/usr/bin/perl -w # # spamhammer - give spammers the meathammer in real time # steve j. kondik # # this code will monitor a postfix log and watch for "550" # errors (user unknown). after a threshold is reached, # the ip generating the excessive errors will be added to # the block list. spammers are sloppy and generally tend to # blast everyone on their list at the same time. # # this is mostly useless unless used on a high-volume server. # # this is tested and used in production on a solaris 9 # mail cluster. # # TODO: make this work with metalog also. # package spamhammerd; use strict; use File::Tail; use File::Grep qw( fgrep ); use POSIX 'setsid'; use IO::Handle; use Sys::Syslog; # logfile to monitor my $filename = "/var/log/postfix/current"; # access file to create my $accessfile = "/etc/postfix/generated/spammers"; # timespan for blocking (in seconds) my $timespan = 300; # number of hits in timespan to be blocked my $hits = 15; # how often to generate the block list my $generate = 30; # how long to block for (in seconds) my $blocktime = (3600 * 48); # how often to purge old entries from the list my $purgedelay = 3600; # ident in syslog my $ident = "spamhammerd"; # path to postfix's postmap command my $postmap = "/usr/sbin/postmap"; # pidfile my $pidfile = "/var/run/spamhammerd.pid"; # internal variables.. my %spammers; # this generates the block file sub dump_hash { if (keys(%spammers) > 0) { my $now = time(); my @blocks; foreach my $key (keys(%spammers)) { if ((($spammers{$key}->{'last_hit'} - $spammers{$key}->{'first_hit'}) < $timespan) && $spammers{$key}->{'hit_count'} > $hits) { # block the fucker push @blocks, $key; } elsif (($now - $spammers{$key}->{'last_hit'}) > $timespan) { delete($spammers{$key}); } } if ($#blocks > -1) { my @realblocks; foreach my $block (@blocks) { if ( ! fgrep { /^$block / } $accessfile ) { push @realblocks, $block; } } if (@realblocks) { open ASSHATS, ">> " . $accessfile; foreach my $asshat (@realblocks) { syslog('info', 'blocking ip: ' . $asshat); print ASSHATS $asshat . " REJECT Automatically blocked due to excessive SMTP errors (" . time() . ")\n"; delete($spammers{$asshat}); } close ASSHATS; system($postmap, $accessfile); } } } } # this sub cleans the accessfile of stale entries sub cleanup { my @newfile; open(CLEANUP, "< " . $accessfile); open(TMPFILE, "> " . $accessfile . ".tmp"); while (my $line = ) { chomp $line; if ($line =~ /blocked due to excessive SMTP errors \((\d+)\)/) { my $stamp = $1; if ((time() - $stamp) < $blocktime) { print TMPFILE $line . "\n"; } } } close TMPFILE; close CLEANUP; rename $accessfile . ".tmp", $accessfile; system($postmap, $accessfile); } # daemonize open STDIN, "/dev/null" or die; open STDOUT, ">/dev/null" or die; defined(my $pid = fork) or die "can't fork!"; if ($pid) { open PIDFILE, ">" . $pidfile or die "Can't open pidfile: $pidfile"; print PIDFILE "$pid\n"; close(PIDFILE); exit; } setsid or die "can't setsid!"; #open STDERR, ">&STDOUT"; # log to syslog openlog($ident, 'cons,pid', 'daemon'); syslog('info', 'spamhammerd starting'); my $last_dump = time(); my $last_clean = time(); # open the file and start watching my $tail = File::Tail->new(name => $filename, resetafter => 30); while (defined(my $line = $tail->read)) { my @sline = split / /, $line; if ($sline[9] eq "reject:") { $sline[12] =~ m/\[(\d+\.\d+\.\d+\.\d+)\]/; my $ipaddr = $1; if ($sline[13] eq "550" && $ipaddr) { my $now = time(); if ($spammers{$ipaddr}) { $spammers{$ipaddr}->{'hit_count'}++; } else { $spammers{$ipaddr}->{'first_hit'} = $now; $spammers{$ipaddr}->{'hit_count'} = 1; } $spammers{$ipaddr}->{'last_hit'} = $now; if ($now - $last_dump > $generate) { dump_hash(); $last_dump = $now; if ($now - $last_clean > $purgedelay) { cleanup(); $last_clean = $now; } } } } }