27 Ноябрь 2017

Проблемы учёта доменной квоты в Dovecot 2

Domain quota accounting problems in Dovecot 2

Несколько лет назад в статье "Двухуровневое квотирование в Dovecot 2" автором была описана система устройства квот пользователь / домен в популярном IMAP4 / POP3 сервере Dovecot. Вкратце, напомню, что она позволяет ограничивать размер занимаемый как конкретным почтовым ящиком, так и всеми принадлежащими данному домену учётным записям. Это решение нашло применение на многих инсталляциях почтовых систем по всему миру.

Однако, в ходе реальной эксплуатации рядом пользователей были замечены некоторые технические проблемы такого решения. Рассмотрим в чём они состоят и как решаются.

1. Проблема с doveadm

Как уже упоминалось в статье "Двухуровневое квотирование в Dovecot 2", в случае развёртывания данной схемы квотирования для уже существующих почтовых ящиков потребуется заполнение базы данных реальными значениями объёма занимаемого места и количества сообщений. Для этого используется стандартная утилита doveadm с параметрами пересчёта квот quota recalc. Однако, одна не поддерживает механизм доменных квот. Поэтому попытка её использования с параметром -u и указанием в качестве имени пользователя имени домена или пересчёта квот всех почтовых ящиков -A завершается неудачей.

root@beta:~ # doveadm quota recalc -u my.domain
doveadm(my.domain): Error: User doesn't exist
root@beta:~ # doveadm quota recalc -A
Error: User listing returned failure
doveadm: Error: Failed to iterate through some users
root@beta:~ # grep dovecot /var/log/maillog | grep sql
Nov 27 17:57:36 beta dovecot: auth-worker(18300): Warning: mysql: Query failed, retrying: Table 'exim.users' doesn't exist
Nov 27 17:57:36 beta dovecot: auth-worker(18300): Error: sql: Iterate query failed: Table 'exim.users' doesn't exist (using built-in default iterate_query: SELECT username, domain FROM users)

При этом, конечно же, с базой данных словарей квот всё в порядке.

root@beta:~ #  mysql -uroot -p -D exim
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 47862
Server version: 5.7.20-log Source distribution

Copyright (c) 2000, 2017, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

root@localhost [exim]> SELECT * FROM quota2 WHERE username LIKE '%my.domain';
+-------------------+-------------+----------+
| username          | bytes       | messages |
+-------------------+-------------+----------+
| foo@my.domain     |  8039401321 |    32474 |
| my.domain         | 12039421270 |    45581 |
| john@my.domain    |  3455382803 |    11142 |
| mary@my.domain    |   544637146 |     1965 |
+-------------------+-------------+----------+
4 rows in set (0.00 sec)
...

Поэтому для пересчёта мы должны будем вручную пройтись по списку пользователей и проверим результат через параметр quota get.

root@beta:~ # doveadm quota recalc -u foo@my.domain
root@beta:~ # doveadm quota get -u foo@my.domain
Quota name   Type      Value Limit                                                                   %
user_quota   STORAGE 7850978     -                                                                   0
user_quota   MESSAGE   32474     -                                                                   0
domain_quota STORAGE 7850978     -                                                                   0
domain_quota MESSAGE   32474     -                                                                   0

Результат вывода запрошенной квоты обескураживает. Он не имеет ничего общего с её реальным размером. Удостоверимся в этом непосредственно в базе данных.

...
root@localhost [exim]> SELECT * FROM quota2 WHERE username LIKE '%my.domain';
+-------------------+-------------+----------+
| username          | bytes       | messages |
+-------------------+-------------+----------+
| foo@my.domain     |  8039401321 |    32474 |
| my.domain         |  8039401321 |    32474 |
| john@my.domain    |  3455382803 |    11142 |
| mary@my.domain    |   544637146 |     1965 |
+-------------------+-------------+----------+
4 rows in set (0.00 sec)
...

Действительно, для пользователя foo@my.domain были расчитаны верные значения, однако, при этом ровно те же значения были записаны и в строку с квотой для домена, а квоты для других почтовых ящиков данного домена проигнорированы. Безусловно, это не нормальная ситуация.

Принимая во внимание вышеизложенное, в качестве средства для решения проблемы был написан shell-скрипт dovequota.sh для корректного расчёта значений, который избавит нас как от необходимости ручного перебора всех почтовых ящиков для пересчёта занимаемых ими объёмов, так и позволит получить верные значения доменных квот.

root@beta:~ # cd /usr/local/etc/dovecot/
root@beta:/usr/local/etc/dovecot # touch dovequota.sh
root@beta:/usr/local/etc/dovecot # chmod +x dovequota.sh
root@beta:/usr/local/etc/dovecot # cat dovequota.sh
#!/bin/sh

# Get or recalculate Dovecot 2 with 2-level quotas for all users or domain
# see https://kostikov.co/dvuhurovnevoe-kvotirovanie-v-dovecot-2
#
# v.20171127 (c)2017 by Max Kostikov http://kostikov.co e-mail: max@kostikov.co
#
# Usage: dovequota.sh get|calc [user|domain]
#
# Requires MySQL and Postfixadmin installed
#

## Settings
# db credentials
DBUSER="exim"
DBPASS="exim"
DBNAME="exim"
SQL="`which mysql` -u $DBUSER -p$DBPASS -D $DBNAME"

# path to doveadm
DOVEADM=`which doveadm`

# Check arguments
if [ $# -lt 1 ]
then
        echo "Provide 'get' or 'calc' as argument with optional user or domain name (empty means all)"
        exit 1
fi

# domain quota recalc
domrecalc()
{
        $SQL -s --disable-column-names -e "UPDATE quota2 t1, (SELECT SUM(bytes) bytes, SUM(messages) messages FROM quota2 WHERE username LIKE CONCAT('%@','$1')) t2 SET t1.bytes = t2.bytes, t1.messages = t2.messages WHERE username = '$1'" 2>/dev/null
}

case $1 in
        get)    $SQL -e "SELECT username, bytes, messages FROM quota2 WHERE username LIKE '%$2'" 2>/dev/null
                ;;
        calc)   if [ $# -eq 1 ]
                then
                        # recalc for all users by domains
                        DOM=`$SQL -s --disable-column-names -e "SELECT username FROM quota2 WHERE username NOT LIKE '%@%'" 2>/dev/null`
                        for i in $DOM
                        do
                                for j in `$SQL -s --disable-column-names -e "SELECT username FROM quota2 WHERE username LIKE '%@$i'" 2>/dev/null`
                                do
                                        $DOVEADM quota recalc -u $j
                                done
                                domrecalc $i
                        done
                else
                        if [ `echo "$2" | grep "@"` ]
                        then
                                # recalc for user
                                MASK="$2"; DOM=`echo $MASK | cut -d '@' -f 2`
                        else
                                # recalc for domain
                                MASK="%@$2"; DOM="$2"
                        fi
                        # get users list
                        MBOX=`$SQL -s --disable-column-names -e "SELECT username FROM quota2 WHERE username LIKE '$MASK'" 2>/dev/null`
                        if [ -z "$MBOX" ]
                        then
                                echo "No users found for recalc"
                                exit 1
                        fi
                        for i in $MBOX
                        do
                                $DOVEADM quota recalc -u $i
                        done
                        domrecalc $DOM
                fi
                ;;
        *)      echo "Wrong usage argument value '$1'!"
                exit 1
                ;;
esac

Данный скипт имеет два основных режима.

  1. Вызов его с ключевым словом get и вторым необязательным аргументом в виде имени домена или имени почтового ящика в виде e-mail выводит информацию о текущих значених квот. Если второго параметра нет, то выводятся все квоты — как пользователей так и доменов.
  2. Вызов с ключевым словом calc приводит к пересчёту квот. При этом, аналогично предыдущему примеру, если имеется второй аргумент, то пересчёт производится для данного почтового ящика или домена. В противном случае пересчитываются все квоты в данной почтовой системе.
root@beta:/usr/local/etc/dovecot # ./dovequota.sh calc foo@my.domain
root@beta:/usr/local/etc/dovecot # ./dovequota.sh get my.domain
+-------------------+-------------+----------+
| username          | bytes       | messages |
+-------------------+-------------+----------+
| foo@my.domain     |  8039401321 |    32474 |
| my.domain         | 12039781150 |    45582 |
| john@my.domain    |  3455382803 |    11142 |
| mary@my.domain    |   544997026 |     1966 |
+-------------------+-------------+----------+

И, конечно же, при любых пересчётах квот корректно ведутся и квоты для соответствующего домена.

2. Проблема с Postfixadmin

Аналогично предыдущей утилите, интерфейс для управления почтовыми аккаунтами и квотами, который предоставляет Postfixadmin, несмотря на то, что также позволяет организовывать двухуровневое квотирование для уровней пользователь / домен, при удалении почтового ящика не корректирует доменную квоту.

В этой связи, разумным подходом в данном случае будет воспользововаться механизмом триггеров на уровне баз данных, который предоставляет MySQL. Добавив соответствующую процедуру на событие DELETE мы сможем исправить эту досадную ошибку.

DELIMITER |
CREATE TRIGGER domain_recalc AFTER DELETE ON mailbox FOR EACH ROW
BEGIN
    SET @dom = SUBSTRING_INDEX(OLD.`username`,'@',-1);
    UPDATE `quota2` t1, (SELECT SUM(`bytes`) bytes, SUM(`messages`) messages FROM `quota2` WHERE `username` LIKE CONCAT ('%@',@dom)) t2 SET t1.bytes = t2.bytes, t1.messages = t2.messages WHERE `username` = @dom;
END;
|
DELIMITER ;

Проверка после добавления.

...
root@localhost [exim]> SHOW TRIGGERS\G
*************************** 1. row ***************************
             Trigger: domain_recalc
               Event: DELETE
               Table: mailbox
           Statement: BEGIN
SET @dom = SUBSTRING_INDEX(OLD.`username`,'@',-1);
UPDATE `quota2` t1, (SELECT SUM(`bytes`) bytes, SUM(`messages`) messages FROM `quota2` WHERE `username` LIKE CONCAT('%@',@dom)) t2 SET t1.bytes = t2.bytes, t1.messages = t2.messages WHERE `username` = @dom;
END
              Timing: AFTER
             Created: 2017-11-27 21:32:07.80
            sql_mode: NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
             Definer: root@localhost
character_set_client: utf8
collation_connection: utf8_general_ci
  Database Collation: utf8_general_ci
1 row in set (0.04 sec)

root@localhost [exim]> QUIT
Bye

Триггер с именем domain_recalc создан. Теперь при удалении почтового ящика из таблицы пользователей 'mailbox' будет производится пересчёт квоты для домена, к которому он относился.

3. PROFIT!

Статья была полезной? Тогда прошу не стесняться и поддерживать деньгами через PayPal или Яндекс.Деньги.


dev  shell  dovecot  MySQL