Очередной привет из прошлого, на этот раз из 2007 года 🙂 Но работает без какого-либо пригляда по сей день!
Дан почтовый сервер на Postfix под FreeBSD, как и любой его собрат постоянно борющийся со спамом, но т.к. большинство пользователей являются внешними, есть ещё одна специфичная проблема: украденные вирусами у пользователей пароли используются для легитимного с точки зрения сервера отправки спама. Для предотвращения таких ситуаций было написано несколько скриптов выполняющих следующие функции:
— Сбор и сохранение в MySQL информации о входящих SMTP соединениях на основании парзинга логов Postfix
— WEB-интерфейс для удобного просмотра текущей ситуации и зачатками статистики
— Уведомление на email в случае превышения лимита соединений с одного IP-адреса
— Автоматическая блокировка IP-адреса при превышении лимита через pf

Почему был избран такой путь, а не использованы более-менее стандартные инструменты наподобие fail2ban, я честно говоря не помню за давностью лет, но думаю, что основной причиной было отсутствие WEB-интерфейса и некоторой статистики.

1. Сбор информации

Таблица MySQL:

CREATE TABLE `log` (
  `date` date default NULL,
  `time` time default NULL,
  `srcip` varchar(15) default NULL,
  `dstip` varchar(15) default NULL,
  `srcaddr` varchar(50) default '',
  `dstaddr` varchar(50) default NULL
) ENGINE=MyISAM DEFAULT CHARSET=latin1;

А где индексы? Сейчас я бы добавил 🙂

Скрипт-парзер:
Выполняется в 05 и 35 минут каждого часа:

5,35	*	*	*	*	/opt/mstat_postfix/mstat_postfix.pl
#!/usr/bin/perl
 
use DBI;
use Time::localtime;
use Fcntl qw(:DEFAULT :flock O_RDWR O_CREAT);
use Date::Manip;
 
test_unique();
 
$logfile = '/var/log/maillog';
$ltime_file = '/opt/mstat_postfix/ltime';
 
$database = 'mstat_postfix';
$dbuser = 'DBUSER';
$dbpassword = 'DBPASSWORD';
 
$tm = localtime;
$year = sprintf("%04d",$tm->year+1900);
 
$dbh = DBI->connect("DBI:mysql:${database}", $dbuser, $dbpassword)
    or die $DBI::errstr;
 
my %rec, $ltime;
 
open(FLTIME, $ltime_file);
$ltime = <FLTIME>;
close(FLTIME);
 
open(MAIL, $logfile);
while ($line = <MAIL>)
{
  my ($month, $day, $time, $hostname, $servicename, $id, $message) = split /\s+/, $line, 7;
  if ($id =~ /([a-z0-9]+)\:/i)
  {
    $id = $1;
    $rec{$id} = {}
      unless ($rec{$id});
 
    if ($message =~ 'removed')
    {
      $rec{$id}->{'removed'}++;
    }
    else
    {
      while ($message =~ /(client|size|from|to)=(\S+?)(\s|,)/g)
      {
        if ($1 eq 'client') {
        $rec{$id}->{'month'} = sprintf "%s", $month;
        $rec{$id}->{'day'} = sprintf "%d", $day;
        $rec{$id}->{'time_sql'} = sprintf "%s", $time;
	        $cur_date = $year.'-'.$rec{$id}->{'month'}.'-'.$rec{$id}->{'day'}.' '.$rec{$id}->{'time_sql'};
	        $date = ParseDate($cur_date);
	        $date_sql = substr($date,0,4).'-'.substr($date,4,2).'-'.substr($date,6,2);
        $rec{$id}->{'date_sql'} = $date_sql;
        $rec{$id}->{'time'} = UnixDate($date,"%s");
        }
        $rec{$id}->{$1} = $2;
      }
    }
  }
}
close(MAIL);
 
foreach my $id (sort { $rec{$a}->{'time'} cmp $rec{$b}->{'time'} } keys %rec)
{
  $rec{$id}->{'client'} =~ s/(.+)\[(\d+\.\d+\.\d+\.\d+)\]/$2/;
  $rec{$id}->{'from'} =~ s/<(.+)>/$1/;
  $rec{$id}->{'to'} =~ s/<(.+)>/$1/;
  $rec{$id}->{'from'} =~ s/'/#/g;
  $rec{$id}->{'to'} =~ s/"/#/g;; 
  if (
      $rec{$id}->{'removed'}  
      &&
      $rec{$id}->{'client'} ne '127.0.0.1'       
      )
  {
            next if ($rec{$id}->{'time'} <= $ltime);
            $date_sql = substr($date,0,4).'-'.substr($date,4,2).'-'.substr($date,6,2);
	    $sql = "INSERT INTO `log` (`date`,`time`,`srcip`,`dstip`,`srcaddr`,`dstaddr`) VALUES (\'$rec{$id}->{'date_sql'}\',\'$rec{$id}->{'time_sql'}\',\'$rec{$id}->{'client'}\',\'0.0.0.0\',\'$rec{$id}->{'from'}\',\'$rec{$id}->{'to'}\')";
	    my $sth = $dbh->prepare( $sql ) or die $DBI::errstr;
	    $sth->execute or die $DBI::errstr;	  
  }
  $ltime_new = $rec{$id}->{'time'};
}
 
open(FLTIME, "> $ltime_file");
print FLTIME $ltime_new; 
close(FLTIME);
 
$dbh->disconnect;
 
exit();
 
sub test_unique(){ 
my @a=split /\//,$0; 
my $lockfile="/var/run/".$a[$#a]."\.lock-2"; 
if (-e $lockfile){ 
if (sysopen(FH, $lockfile, O_WRONLY) && flock(FH, LOCK_EX|LOCK_NB)){ 
return 0; 
} else { print "Script $a[$#a] alredy started!!!\n"; exit(); } 
} 
sysopen(FH, $lockfile, O_WRONLY|O_CREAT) && flock(FH, LOCK_EX|LOCK_NB) || 
die $!; 
return 0; 
}

2. WEB-интерфейс

Отдельно не выкладываю, т.к. большой и не интересный, можно скачать внизу вместе со всем остальным.

3. Уведомления и автоматизация

Выполняется в 15 и 45 минут каждого часа:

15,45	*	*	*	*	/opt/mstat_postfix/mail.pl
#!/usr/bin/perl
 
use Net::SMTP;
use DBI;
use Time::localtime;
use Fcntl qw(:DEFAULT :flock O_RDWR O_CREAT);
 
$database = 'mstat_postfix';
$dbuser = 'DBUSER';
$dbpassword = 'DBPASSWORD';
 
$dbh = DBI->connect("DBI:mysql:${database}", $dbuser, $dbpassword)
    or die $DBI::errstr;
 
$tm = localtime;
$cur_hour = sprintf("%02d",$tm->hour);
$cur_date = sprintf("%04d-%02d-%02d",$tm->year+1900,($tm->mon)+1,$tm->mday);
$ALARM = 0;
$UPDATEpf = 0;
$sql = "SELECT hour(`time`) as `ht`, count(`srcip`) as `cs`, `srcip` FROM `log` WHERE ((`date` = \'$cur_date\') AND (`time` LIKE \'$cur_hour%\')) GROUP BY `ht`,`srcip` ORDER BY `cs` DESC;";
my $sth = $dbh->prepare( $sql ) or die $DBI::errstr;
$sth->execute or die $DBI::errstr;
my ($ht,$cs,$srcip);
$rv = $sth->bind_columns(\($ht,$cs,$srcip));
while ($sth->fetch) {
if ($cs >= 100){$ALARM = 1;};
if ($cs >= 150){
system("echo $srcip >> /etc/pf.mailblock");
$UPDATEpf = 1;
};
};
 
if ($UPDATEpf eq 1){
    system("/sbin/pfctl -f /etc/pf.conf");
};
 
if ($ALARM eq 1){
 
$mailfrom = 'from@domain.ru';
$smtppass = 'PASSWORD';
$mailto = 'to_admin@domain.ru';
 
$smtp = Net::SMTP->new('smtp.domain.ru');
$smtp->auth($mailfrom, $smtppass);
$smtp->mail($mailfrom);
$smtp->to($mailto);
 
$smtp->data();
$smtp->datasend("From: $mailfrom\n");
$smtp->datasend("To: $mailto\n");
$smtp->datasend("Subject: Posibly SPAM activity on HOSTING server!\n");
$smtp->datasend("\n");
$smtp->datasend("Detected posibly SPAM activity on HOSTING server!\n");
$smtp->datasend("Please visit http://mailstat.domain.ru/mstat/mstat.pl to check.\n");
$smtp->dataend();
$smtp->quit;
};
$rc  = $dbh->disconnect;
exit();

Схема работы очень простая: считаем количество соединений с одного IP в текущем часу и если больше лимита (100) отправляем предупреждение на почту, а если больше второго лимита (150), то и пишем IP нарушителя в файл /etc/pf.mailblock, а после обновляем pf.

И в завершении пример конфигурации pf:

##########
# Macros #
##########
 
# Interface
ext_if          = "bge1"
ext_ip          = "IP1/25"
 
int_if          = "bge0"
int_ip          = "IP2/32"
 
 
# My services for all
tcp_svc         = "ftp-data:ftp smtp domain http pop3 https 2000:2100 1055"
udp_svc         = "domain 1055"
 
# My services for NOT all
n_tcp_svc         = "ftp-data:ftp smtp domain http pop3 https 2000:2100 953"
n_udp_svc         = "domain 953"
 
block_out_ports = "{ 8080, 6666, 6667, 6668, 6669, 3264 }"
our_ips = "{ IP1, IP2, 192.168.1.1, 192.168.1.2 }"
 
# Tables
table <hack> persist file "/etc/pf.block"
table <mailblock> persist file "/etc/pf.mailblock"
 
 
###################
# Runtime Options #
###################
set skip on lo0
set state-policy if-bound
set optimization conservative
set ruleset-optimization basic
 
 
#########
# Begin #
#########
scrub in all
 
 
##################
# Default policy #
##################
block in log on $ext_if
pass out quick on $ext_if inet proto tcp from any to $our_ips port $block_out_ports
block out quick on $ext_if inet proto tcp from any to any port $block_out_ports
pass out keep state
 
 
############################
# Blocking spoofed packets #
############################
antispoof quick for $ext_if
block in quick from urpf-failed
 
 
###################
# Incoming policy #
###################
 
# Block hack
block in quick from <hack> to any
block in quick from <mailblock> to any
 
# ICMP
pass in on $ext_if inet proto icmp icmp-type echoreq code 0 keep state
 
# SSH
pass in on $ext_if inet proto tcp from any to $ext_if port {ssh} flags S/SA keep state (max-src-conn 5, max-src-conn-rate 3/300, overload <ssh_block> flush)
block on $ext_if inet proto tcp from <ssh_block> to $ext_if port {ssh} probability 65%
 
# Services
pass in on $ext_if inet proto udp from any to $ext_if port {$udp_svc} keep state
pass in on $ext_if inet proto tcp from any to $ext_if port {$tcp_svc} flags S/SA keep state
 
pass in on $int_if inet proto udp from any to $int_if port {$n_udp_svc} keep state
pass in on $int_if inet proto tcp from any to $int_if port {$n_tcp_svc} flags S/SA keep state

Скачать архив: mailstat.zip