Проверка квоты пользователя на этапе RCPT

На одном из почтовых серверов появилась необходимость отшибать письма (до приема содержимого письма) для пользователей, у которых исчерпан отведенный им лимит дискового пространства. В качестве MTA на сервере используется Exim. Как известно стандартными средствами данного MTA такое не сделать, но в силу гибкости Exim,а можно выйти из положения написав скрипт, который будет проверять лимиты пользователей и посредством сокетов взаимодействовать с ним (см. в доках readsocket). Что и было реализовано в скрипте, приведенном немного ниже. Скрипт запускается в фоне и обслуживает запросы Exim,а на проверку превышения квоты пользователем. На входе скрипт имеет полный почтовый адрес пользователя, а на выход выдает одну из заранее определенных констант:

  • QUOTA_OK — квота не исчерпана;
  • QUOTA_EXCEEDED — квота исчерпана;
  • QUOTA_UNKNOWN — по какой-либо причине не удалось установить состояние квоты;
  • QUOTA_ERROR — произошла ошибка на стороне скрипта.

Вот собственно код скрипта на Perl:

#!/usr/bin/env perl

#
# Для работы требуется модуль DBD::Pg
#

use strict;
use warnings;
use Socket;
use POSIX;
use DBI;

my $work_dir = "/path/to/workdir";
my $sock_name = "/tmp/ckmq.sock";
my $log_file = "/path/to/log/ckmq_daemon.log";
my $pid_file = "/var/run/ckmq.pid";

my $SOCK_FD = -1;
my $srv_status = 1;
my $sock_addr = sockaddr_un($sock_name);
my $max_forks = 15;
my $fork_count = 0;
my $pid = -1;
my $dbh = -1;

my $db_host = '127.0.0.1';
my $db_user = 'dbuser';
my $db_pass = 'dbpass';
my $db_name = 'dbname';

#--------------------------------------------------------------------

# Переключаемся в режим демона
defined($pid = fork())          || die "can't fork: $!\n";
exit(0) if $pid;
(setsid() != -1)                || die "can't start new session: $!\n";

open(PID_FILE, '>', $pid_file)  || die "cat't write pid: $!\n";
print(PID_FILE $$);
close(PID_FILE);

open(LOG_FILE, '>>', $log_file) || die "can't write $log_file: $!\n";
open(STDIN, '<', '/dev/null')   || die "can't read /dev/null: $!\n";
open(STDOUT, '>', '/dev/null')  || die "can't write /dev/null: $!\n";
open(STDERR, '>', '/dev/null')  || die "can't write /dev/null: $!\n";

$SIG{INT} = \&sig_int;
$SIG{TERM} = \&sig_int;
$SIG{CHLD} = \&sig_chld;

socket(SOCK_FD, AF_UNIX, SOCK_STREAM, 0) || &error_log("$!");
bind(SOCK_FD, $sock_addr)                || &error_log("$!");
listen(SOCK_FD, SOMAXCONN)               || &error_log("$!");
chmod(0777, $sock_name)                  || &error_log("can't chmod: $!");
chdir($work_dir)                         || &error_log("can't chdir: $!");

¬ice_log("check quota daemon started (pid: $$)");
while($srv_status){
 my $CLIENT_FD = -1;

 if (!accept(CLIENT_FD, SOCK_FD)){ next; }

 if ($fork_count >= $max_forks){
   send(CLIENT_FD, "QUOTA_ERROR", 0);
   shutdown(CLIENT_FD, 2);
   close(CLIENT_FD);
   ¬ice_log('fork_max exceeded');
   next;
 }

 $fork_count++;
 if (fork() == 0){
   close(SOCK_FD);

   $SIG{INT} = 'IGNORE';
   $SIG{TERM} = 'DEFAULT';
   $SIG{CHLD} = 'IGNORE';
   setsockopt(CLIENT_FD, SOL_SOCKET, SO_RCVTIMEO, pack('L!L!', 5, 0));
   &client_work($CLIENT_FD);

   shutdown(CLIENT_FD, 2);
   close(CLIENT_FD);
        
   exit(0);
 }
 close(CLIENT_FD);
}

if ($SOCK_FD != -1){
 shutdown(SOCK_FD, 2);
 close(SOCK_FD);
}
unlink($sock_name);

¬ice_log('check quota daemon stopped');

exit(0);

#--------------------------------------------------------------------

sub error_log {
 my $message = shift;
 my ($sec, $min, $hour, $mday, $mon) = localtime();
 print(LOG_FILE "$mon $mday $hour:$min:$sec error: $message\n");

 if ($SOCK_FD != -1){
   shutdown(SOCK_FD, 2);
   close(SOCK_FD);
 }

 exit(0);
}

sub notice_log {
 my $message = shift;
 my ($sec, $min, $hour, $mday, $mon) = localtime();
 print(LOG_FILE "$mon $mday $hour:$min:$sec notice: $message\n");
}

sub sig_int {
 $srv_status = 0;
 shutdown(SOCK_FD, 2);
 close(SOCK_FD);
 $SOCK_FD = -1;
}

sub sig_chld {
 my $pid = -1;

 while (($pid = waitpid(-1, WNOHANG)) > 0){
   $fork_count--;
 }
}

sub client_work {
 my $CLIENT_FD = shift;
 my $buffer = '';
 my $user_name = '';
 my @email = ();
 my ($quota_size, $mail_root) = (0,0);

 if (!defined(recv(CLIENT_FD, $buffer, 255, 0))){ return; }
 if (!$buffer){ return; }

 &db_connect();

 chomp($buffer);
 @email = split(/@/, $buffer);
 #print("Name: ", $email[0], "\nDomain: ", $email[1], "\n");
 if (defined($buffer = &db_get_user_from_alias($email[0], $email[1]))){
   @email = split(/@/, $buffer);
 }

 ($quota_size, $mail_root) = &db_get_user_quota($email[0], $email[1]);
 $quota_size = $quota_size * 1024;

 $buffer = "$mail_root/$email[1]/$email[0]/Maildir/maildirsize";
 if (open(MDSF, "<", $buffer)){
   my $total_size = 0;

   $buffer = <MDSF>;
   while (<MDSF>){
       my ($tmp) = split(/ /);
       $total_size += $tmp;
   }
   #¬ice_log("Quota size: $quota_size");
   #¬ice_log("Total maildir size: $total_size");
   send(CLIENT_FD, ($quota_size == 0) || ($total_size < $quota_size) ? 'QUOTA_OK' : 'QUOTA_EXCEEDED', 0);

   close(MDSF);
 } else {
   send(CLIENT_FD, 'QUOTA_UNKNOWN', 0);
 }

 &db_disconnect();

 undef;
}


#--------------------------------------------------------------------
sub db_connect {
 $dbh = DBI->connect("dbi:Pg:host=$db_host;dbname=$db_name", $db_user, $db_pass, {AutoCommit => 0});
}

sub db_disconnect {
 $dbh->disconnect();
}

# Функция возвращает массив из двух элементов: путь до почтового ящика и размер квоты
sub db_get_user_data {
 my ($uname, $dname) = @_;
 my $stm = -1;
 my @row = ();

 @row = $dbh->selectrow_array('SELECT "users_tb"."quota", "users_tb"."homedir" FROM "users_tb"
   INNER JOIN "domains_tb" ON ("users_tb"."domain_id" = "domains_tb"."id")
   WHERE "username" = $$' . $uname . '$$ AND
     "domains_tb"."domainname" = $$' . $dname . '$$ LIMIT 1;');

 return ($#row == 2) ? @row : -1;
}

# Функция преобразовывает алиас в реальное имя пользователя
sub db_get_user_from_alias {
 my ($uname, $dname) = @_;
 my $stm = -1;
 my @row = ();

 @row = $dbh->selectrow_array('SELECT "aliases_tb"."mailaddr" FROM "aliases_tb"
     INNER JOIN "domains_tb" ON ("aliases_tb"."domain_id" = "domains_tb"."id")
     WHERE "aliases_tb"."aliasname" = $$'.$uname.'$$ AND
       "domains_tb"."domainname" = $$'.$dname.'$$ AND
       "aliases_tb"."active" = $$true$$ AND "domains_tb"."active" = $$true$$');

 return $row[0];
}

Данный скрипт написан для почтовой системы, описанной в данной статье. Думаю, переделать под какую-либо другую конфигурацию не составит особого труда. Для автоматического запуска во время старта системы можно добавить его в автозагрузку, например, создать файл в /usr/local/etc/rc.d с таким содержимым:

#!/bin/sh

# PROVIDE: ckmq
# BEFORE: exim
# KEYWORD: shutdown
#
# Add the following lines to /etc/rc.conf to enable Check Mail Quota Daemon:
# ckmq_enable (bool):    Set to "NO" by default.
#                       Set it to "YES" to enable ckmq.
#

. /etc/rc.subr

name="ckmq"
rcvar=ckmq_enable

load_rc_config ${name}

: ${ckmq_enable="NO"}

pidfile="/var/run/${name}.pid"
command="/path/to/script"

run_rc_command "$1"

Правило для Exim,а будет выглядить так:

deny message = Quota size exceeded
      domains = +local_domains
      condition = ${if eq{QUOTA_EXCEEDED}{${readsocket{/tmp/ckmq.sock}{$local_part@$domain}{3s}{}{false}}}{yes}{no}}

Поскольку скрипт не может узнать размер принимаемого сообщения, то в MTA или MDA необходимо отключить проверку квот, чтобы допустить переполнение почтового ящика. В остальном скрипт довольно прост для понимания, поэтому я не буду подробно расписывать каждую строчку. Если есть вопросы по работе скрипта, то задавайте их в комменты или в форум.

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

CAPTCHA
Этот вопрос задается для того, чтобы выяснить, являетесь ли Вы человеком или представляете из себя автоматическую спам-рассылку.
Яндекс.Метрика