Вариант имени Алиеча

Мы пойдём своим путём!

Пример интеграции dovecot-antispam и sa-learn

Итак, мы знаем, как определяется спам и как его положить в отдельную папку на сервере. Как вы понимаете, всегда будет некоторое количество спама, которое таки прилетит во «Входящие», и некоторое количество нормальных писем, которые улетят в «Спам» (Junk). И нам надо дать пользователю шанс оперативно известить нас… Нет, не так. Нас оповещать не надо — мы ленивые. Пусть он это сообщить нашей проверочной программе, то есть SpamAssassin’у, в нашем случае.

В вики dovecot’а есть статья, но она не очень полезна, если у нас разные uid’ы на ящиках, так как там предполагается, что sa-learn вызывается из скрипта (без перенаправления ввода нельзя отдать sa-learn’у письмо, так что без «обёртки» не обойтись) от имени пользователя. То есть с тем же uid’ом, что и у процесса imap пользователя. И будем, значит, мы учить bayes-фильтр не для того пользователя системы? Как же быть? Собирать спам централизованно, а потом уже учить основной фильтр!

Сначала конфиг плагина:

plugin {
    antispam_backend = pipe
    antispam_trash = trash;Trash;Deleted Items; Deleted Messages
    antispam_spam = Junk

    antispam_pipe_program_spam_arg = spam
    antispam_pipe_program_notspam_arg = ham

    antispam_pipe_program = /etc/dovecot/dov_spam_store.pl
    antispam_pipe_program_args = %h
}

Ну да, и снова perl 🙂 Короче, механика проста:

  • если пользователь перемещает что-то в папку Junk, то оно отдаётся perl-скрипту как подлежащее отдаче sa-learn’у как спам;
  • если пользователь перемещает что-то из папки Junk не в «Удалённые», то оно подлежит обучению как не спам.

Текст скрипта /etc/dovecot/dov_spam_store.pl:

#!/usr/bin/perl
################################################################################
# dov_spam_store.pl - скрипт сохранения спама (ну или не спама),
# полученного из dovecot-antispam.
# Пинимает флаг ham/spam как первый аргумент, и путь до рабочей директории.
################################################################################

use strict;
use warnings;

use Fcntl qw(:flock SEEK_END);
use UUID::Tiny qw(:std);
use Digest::SHA qw(sha256_hex);
use DBI;


# Наша конфигурация. Мы её назначаем, да. Ибо если её считать,
# то это потребует от нас открывать файл, читать и тратить драгоценное время.
my %conf = (
	'ttl'	=> 43200,
	'db'	=> "dov_spam.db",
	'dir'	=> "dov_spam",
	'limit'	=> 2097152
);


# Проверяем наличие и содержание аргументов. Да, руками. Ож очень не хочется
# тащить ничего типа Getopt::Long.
if($#ARGV != 1) {
	die("This script can accept only two argument. You can't supply more or less argument count!\n");
}

if(not -d $ARGV[0]) {
	die("First argument must be a patch to existent directory!\n");
}

if($ARGV[1] !~ /^(?:ham|spam)$/) {
	die("Second argument must be an ham/spam flag. Nothing else!\n");
}


# Таки меняем рабочую директорию, начинаем проверять необходимые для нас
# файлы и каталоги. Создаём отсутсвующие сразу, по-возможности.
chdir($ARGV[0]) or die("Can't change work directory: " . $! . "\n");

my $db_isnt_exists = 0;
if(not -f $conf{'db'}) {
	$db_isnt_exists = 1;
}

if(not -d $conf{'dir'}) {
	mkdir($conf{'dir'}) or die("Can't create directory for saved messages: " . $! . "\n");
	chmod(0755, $conf{'dir'}) or die("Can't set chmod on saved messages directory: " . $! . "\n");
}


# Для начала нам не помешало бы получить сообщение. Мы должны создать буфер и сгенерирвоать UUID.
# После этого мы ставим STDIN в binmode. Потом получаем сообщение. Крутим цикл, пока будем получать не менее
# 4096 байт, и пока не получим больше лимита. Если меньше 4096 получили, значит сообщение кончилось.
# Если суммарно более лимита - то просто закрываем глаза на это сообщение, ибо его и sa-learn не сожрёт.
# В конце мы посчитаем контрольную сумму.
my $buffer = "";
my $uuid = create_uuid_as_string(UUID_TIME);

binmode(STDIN);

my $bytes_size = 0;
while(1) {
	my $bytes_fragment = read(STDIN, my $data_fragment, 4096);

	# не смогли прочитать
	if(not defined($bytes_fragment)) {
		die("Can't read from STDIN: " . $! . "\n");
	}
	# прочитали, но нуль байтов
	elsif($bytes_fragment == 0) {
		last();
	}

	$buffer = $buffer . $data_fragment;
	$bytes_size = $bytes_size + $bytes_fragment;

	# превысили лимит
	if($bytes_size > $conf{'limit'}) {
		warn("Message has size more than specified in limit!\n");
		exit(0);
	}
	# и если фрагмент был не полный - а значит сообщение кончилось, заканчиваем цикл
	elsif($bytes_size < 4096) {
		last();
	}
}

my $digest = sha256_hex($buffer);


# Открыаем базу. Если её не было (мы это проверили вначале), создаём таблицу.
my $dbh = DBI->connect("dbi:SQLite:dbname=" . $conf{'db'}, "", "", {
	RaiseError	=> 0,
	PrintError	=> 0
}) or die("Can't open messages database: " . $DBI::errstr . "\n");

if($db_isnt_exists) {
	$dbh->do("CREATE TABLE messages (uuid TEXT NOT NULL, digest TEXT NOT NULL, time INTEGER NOT NULL, flag TEXT NOT NULL, PRIMARY KEY (uuid));") or die("Can't create table in new created messages database: " . $dbh->errstr . "\n");
}


# Делаем выборку, ищем сообщение с таким дайджестом, которое находится выше отсечки по времени.
my ($db_uuid, $db_flag) = $dbh->selectrow_array("SELECT uuid, flag FROM messages WHERE digest = ? AND time >= ? LIMIT 1;", undef, (
	$digest,
	time() - $conf{'ttl'}
));


# нашли что-то
if((defined($db_uuid)) and (defined($db_flag))) {
	# передумал?
	if($db_flag ne $ARGV[1]) {
		unlink($conf{'dir'} . "/" . $db_uuid . ".eml") or warn("Can't remove previosly saved message: " . $! . "\n");
		$dbh->do("DELETE FROM messages WHERE uuid = ?;", undef, ($db_uuid));
	}
	# ещё разок решил? о_О
	else {
		$dbh->do("UPDATE messages SET time = ? WHERE db_uuid = ?;", undef, (time(), $db_uuid));
	}
}
# ничего не нашлось?
else {
	my $rel_path = $conf{'dir'} . "/" . $uuid . ".eml";
	write_message(\$buffer, $rel_path) or die("Can't write message into file " . $ARGV[0] . "/" . $rel_path . "!\n");

	$dbh->do("INSERT INTO messages (uuid, digest, time, flag) VALUES (?, ?, ?, ?);", undef, (
		$uuid,
		$digest,
		time(),
		$ARGV[1]
	)) or die("Can't insert entry in database: " . $dbh->errstr . "\n");
}


# Закончили работу, закрыаем базу
$dbh->disconnect();


# Всем пока 
exit(0);


# Функция для записи файла сообщения.
sub write_message {
	my $content = shift();
	my $file = shift();

	my $fh = undef;
	unless(open($fh, ">", $file)) {
		warn("Can't open file: " . $! . "\n");
		return(undef);
	}

	unless(flock($fh, LOCK_EX)) {
		warn("Can't set lock on file: " . $! . "\n");
		close($fh);
		return(undef);
	}

	binmode($fh);
	print($fh $$content);

	close($fh);

	return(1);
}

Главное назначение данного скрипта — сбор писем к обучению. Побочное — исключения ситуации, когда пользователь «передумает» насчёт своего решения. То есть если такое происходит, то из базы сообщений, подлежащих к обучению, удаляется запись о письме. Ну и само письмо. Для того, чтобы всё это шустро работало и выборки не пришлось ручками писать над текстовым логом, я использовал SQLite. А вообще можно почитать комментарии в коде 😉

Ну и скрипт-агрегатор, /etc/dovecot/sa_learn_collector.pl:

#!/usr/bin/perl
################################################################################
# sa_learn_collector.pl - скрипт забирающий выгруженные из dovecot
# сообщения, которые надо передать sa-learn для обучения.
# За процесс выгрузки овтечает скрипт dov_spam_store.pl!
################################################################################

use strict;
use warnings;

use DBI;
use File::Path qw(make_path remove_tree);
use File::Copy;


# Наша конфигурация. Мы её назначаем, да. Ибо если её считать,
# то это потребует от нас открывать файл, читать и тратить драгоценное время.
my %conf = (
	'mail_root'	=> "/srv/mail",
	'ttl'		=> 43200,
	'db'		=> "dov_spam.db",
	'dir'		=> "dov_spam",
	'limit'		=> 2097152,
	'tmp'		=> "/tmp/sa_learn_collector"
);


# Список ящиков надо забрать и установить отсечку выборки, создать счётчики
my $mboxes = get_homes_list(\%conf) or die("Can't get mboxes list!\n");
my $target = time() - $conf{'ttl'};

my $ham = 0;
my $spam = 0;

my $err = undef;


# И создать необходимые нам директории.
make_path($conf{'tmp'}, $conf{'tmp'} . "/ham", $conf{'tmp'} . "/spam", {
	'chmod'	=> 0755,
	'error'	=> \$err
});

if(@$err) {
	die("Error at tmp directory create stage!\n");
}


# Обрабатываем каждую директорию пользователя (mbox).
foreach my $mbox (@{$mboxes}) {
	my $db_file = $mbox . "/" . $conf{'db'};

	if(not -f $db_file) {
		next();
	}

	my $dbh = DBI->connect("dbi:SQLite:dbname=" . $db_file, "", "", {
		RaiseError	=> 0,
		PrintError	=> 0
	}) or die("Can't open messages database (" . $db_file . "): " . $DBI::errstr . "\n");

	my $messages = $dbh->selectall_arrayref("SELECT uuid, flag FROM messages WHERE time < ?;", undef, ($target)) or die("Can't fetch messages list for mbox " . $mbox . ", SQL error occured: " . $dbh->errstr() . "\n");

	foreach my $message (@{$messages}) {
		my $source_file = $mbox . "/" . $conf{'dir'} . "/" . $message->[0] . ".eml";

		copy($source_file, $conf{'tmp'} . "/" . $message->[1] . "/") or die("Can't copy file (" . $source_file . ") to tmp folder!\n");
		unlink($source_file) or die("Can't delete source file " . $source_file . ": " . $! . "\n");
		chmod(0644, $conf{'tmp'} . "/" . $message->[1] . "/" . $message->[0] . ".eml") or die("Can't set chmod on tmp file: " . $! . "\n");

		$dbh->do("DELETE FROM messages WHERE uuid = ?;", undef, ($message->[0])) or die("Can't delete message entry from database (" . $db_file . "): " . $dbh->errstr() . "\n"); 

		if($message->[1] eq "ham") {
			$ham++;
		}
		else {
			$spam++;
		}
	}

	$dbh->disconnect() or die("Can't disconnect from database (" . $db_file . "): " . $dbh->errstr() . "\n");
}


# Понакопировали всякго?) Пора учить))
if($ham > 0) {
	system("su - debian-spamd -c \"sa-learn --no-sync --max-size " . $conf{'limit'} . " --ham " . $conf{'tmp'} . "/ham/\"");
}

if($spam > 0) {
	system("su - debian-spamd -c \"sa-learn --no-sync --max-size " . $conf{'limit'} . " --spam " . $conf{'tmp'} . "/spam/\"");
}

if(($ham > 0) or ($spam > 0)) {
	system("su - debian-spamd -c \"sa-learn --sync\"");
}


# И удаляем за собой директорию.
remove_tree($conf{'tmp'}, {
	error => \$err
});

if(@$err) {
	die("Error at tmp directory delete stage!\n");
}


# Всем пока ;)
exit(0);


# Функция забора списка домашних директорий.
# Возвращает ref на список.
sub get_homes_list {
	my $config = shift();

	my @list = ();

	my $fh = undef;
	unless(open($fh, "-|", "/usr/sbin/mailadmc list_users")) {
		warn("Can't get users list from mailadmc: " . $! . "\n");
		return(undef);
	}

	my @lines = <$fh>;
	close($fh);

	foreach my $line (@lines) {
		if($line =~ /^(\S+)\s/) {
			push(@list, $config->{'mail_root'} . "/" . $1);
		}
		else {
			return(undef);
		}
	}

	return(\@list);
}

Внимательный читатель быстро заметит, что я работаю из-под Debian. Ну и сам скрипт заточен на взаимодействие с mailadm. Опять так никто вам не мешает его поправить под свои нужды. Тут важно поменять источник информации об учётках (выборка из SQL-базы, обход файла, вывод doveadm?) и замена путей/системных имён. Не сложно 😉

Скрипт же великолепно запускается из cron’а…

И да, не забывайте чистить пользовательскую папку «Спам». Как-то так повелось, что сами они об этом не думаю. А когда начинают думать — получается только хуже. Однажды так 500 писем попало на обучение как не спам (ошибка пользователя). Вся это вакханалия с скриптами с этого и началась… Опять таки, это не сложно, особенно в Dovecot’e:

# doveadm expunge -A MAILBOX Junk SAVEDBEFORE 2w
# doveadm expunge -A MAILBOX Junk NOT HEADER X-Spam-Flag YES SAVEDBEFORE 12h

Или если вы используете mailadm, /etc/dovecot/clean_junk.sh:

#!/bin/sh

IFS='
'

for name in `/usr/sbin/mailadmc list_users | awk '{print $1}'`; do
	/usr/bin/doveadm expunge -u $name MAILBOX Junk SAVEDBEFORE 2w
	/usr/bin/doveadm expunge -u $name MAILBOX Junk NOT HEADER X-Spam-Flag YES SAVEDBEFORE 12h
done

exit 0

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Можно использовать следующие HTML-теги и атрибуты:
<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong> 

22 − 21 =