Справочник email адресов компании или репликация данных в нестабильной сети

в 9:32, , рубрики: perl, php, postfix, системное администрирование, метки: , ,

Введение

Довелось мне работать админом в крупной компании, имеющей более 10-ти филиалов в разных городах, объединенных достаточно нестабильными и медленными каналами. Как и во многих других, основу обмена информацией в компании представляла электронная почта. Следует отметить что на каждом филиале, как и в головной конторе имеется свой почтовый сервер, управление почтовыми аккаунтами производится местным админом.
Задача основная — поддержка актуального справочника email адресов всех пользователей компании для внутреннего использования, второстепенная — список имеющихся email адресов для основного почтового сервера, дабы он проверял есть ли такой адрес в компании перед тем как переслать письмо на нужный филиал.
Каналы нестабильны, частенько «падают», скорость их работы вообще непредсказуема. Кому интересно как была решена задача приглашаю под кат.

Анамнез

Почтовая топология, если так можно назвать схему взаимоотношений почтовых серверов компании, звезда. Существует один транзитный сервер, доступный извне, через который происходит обмен почтой между филиалами компании, а также между пользователями компании и внешним миром. На сервере все проходящие сообщения проверяются на вирусы, входящие извне еще и на спам. Почтовых ящиков на сервере нет, это чисто SMTP relay.
Остальные сервера находятся физически в офисах филиалов и один в головном офисе. Выхода во внешний мир не имеют, получают входящие сообщения с главного сервера, исходящие отправляют через него же. Основная функция — обслуживание почтовых ящиков конечных пользователей.
Все сервера работают под управлением FreeBSD версий 7.x — 8.x, в качестве SMTP используется Postfix, в качестве POP3/IMAP Courier-IMAP.

Откуда берутся данные

Почтовые аккаунты пользователей хранятся в БД MySQL, управление ими производится админами на местах посредством web интерфейса, написанного на PHP. На филиалах простенькая моя разработка еще 2005г. на PHP 4, в головном офисе красивое приложение на Yii с использованием AJAX фич. Написано также мной в рамках изучения фреймворка.

Решение

Первоначально справочник хранился на сервере в виде HTML страницы, содержащей списки всех email-ов пофилиально. Со временем такое решение перестало меня удовлетворять по следующими причинам, в порядке убывания важности:

  • Спамеры. Люди просты и ленивы, очень многие в компании на просьбу сообщить чей-нибудь адрес просто сохраняли страницу и отправляли ее почтой. Список email адресов тиражировался с удивительной скоростью
  • Надежность. Списки формировались раз в сутки на каждом сервере скриптом по cron, потом главный сервер, так же по cron собирал их и формировал ту самую HTML страницу. Если не было канала информация не обновлялась, сохранение предыдущей копии при повторном отсутствии связи следующей ночью приводило к пробелу на месте филиала в списке. Конечно скрипты развивались и умнели, но как это все было не красиво.
  • Удобство. По хорошему поставить бы этот пункт первым, но я админ и не всегда парюсь удобством пользователей пока мне не начнут выносить моск. Для меня главное чтобы все работало и требовало минимум моего вмешательства. Но, как бы то ни было, справочник рос, были добавлены поля для номеров телефонов внутренних и городских, а также должности, подразделения и номера кабинетов. Эти поля заполнялись опционально, но именно благодаря им справочник стал еще и телефонным. Искать человека в таком списке становилось все сложнее. На филиалах с медленными каналами загрузка его становилась все дольше, в общем назревало нечто.

Выбор транспорта

Долго думал как все сделать, перебрал варианты от репликации встроенными в MySQL средствами до написания собственных сервисов, слушающих сетевые порты. Ответ же все это время стоял рядом и ухмылялся. Это все связано с электронной почтой? Ну так вот вам и транспорт. Реалтайм с погрешностью в доли секунды нам не нужен, в пределах минуты же все будет работать при наличии канала. При отсутствии канала данные встанут в очередь и подождут, потом будут доставлены. В общем нам только отправить, остальное не наша забота.
Итак, решено, обмен данными будет происходить посредством email сообщений.

Алгоритм

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

Реализация

Первым делом определимся с форматом команд. Команды будут передаваться в теле письма. Содержимое письма будет text/plain, вложений не будет, а значит MIME multipart с кодированием всего в base64 или quoted-printable не будет.

Тема письма будет содержать уникальный код подразделения (филиала) чтобы однозначно идентифицировать какие данные будем менять. Также это небольшая защита от «хулиганов» если таковые прочухают алгоритм работы нашей системы, не зная кода подразделения (довольно мудреного) никаких изменения нельзя будет произвести, письмо с неизвестным кодом будет просто проигнорировано. В теле письма может быть произвольное количество строк вида:

_cmd=insert:type=mbox:name=Иванов Иван Иванович:eml=ivanov@company.ru:vis=1:gor=88-02-93:mer=38-32-93:sot=:of=214:post=Начальник участка:sdiv=Производственное управление
_cmd=update:type=mbox:eml=petrov@company.ru:name=Петров Петр Петрович:act=1:vis=1:post=стажер:of=822:mer=27-71:gor=88-02-49:sot=:sdiv=Техническая дирекция.
_cmd=delete:type=mbox:eml=sidorov@company.ru

Как видно из этих строк однозначно идентифицировать запись у нас будет email адрес. Обычно в письме будет одна команда (строка) потому как для оперативности накапливать команды нет смысла. Однако изредка разными скриптами будет использоваться возможность использования одного письма для целого пакета команд.

Принимать команды будет скрипт, написанный на perl. Для передачи писем с командами во входной поток скрипта будет использоваться доступный только из внутренней сети почтовый ящик. Для реализации в моей системе (postfix во FreeBSD) я просто создал такой почтовый псевдоним в /etc/mail/aliases:

userdir: "|/home/admin/directory/userdir.pl"

Здесь я приведу сильно укороченный его листинг userdir.pl, дабы не перегружать и без того большую статью:

#!/usr/bin/perl -w
 
use Email::Simple;
use MIME::Base64 ();
use DBI;
...
 
 
# Хэш имен полей для конвертирования команд системы в SQL
my %fnames = (
    name => 'name',
    gor => 'gtel',
    ...
);
...
 
# Сообщение как есть поступает на входной поток нашего скрипта
# Записываем его целиком в переменную
while(<>)
{
    $message .= $_;
}
 
# Парсим переменную, выделяя нужные нам данные
my $email = new Email::Simple($message);
 
my $from = $email->header("From");
my $to = $email->header("To");
my $subj = $email->header("Subject");
...
 
# По теме письма определяем идентификатор подразделения
my $sth = $dbh->prepare("select id from divs where code=?");
if ($sth->execute($subj))
{
    if (defined($div_code = $sth->fetchrow())) { $div_id = $div_code; }
}
$sth->finish();
...
 
if ($div_id ne 'empty')
{
    $cmd =~ s/n//;
    #$cmd =~ tr/ //g;
 
    my @lines = split(/;/, $cmd);
    ...
 
    foreach $line (@lines)
    {
        # Тут непосредственно происходит разбор команды
        chomp($line);
        $line =~ s/n//;
 
        if ($line =~ /^_cmd/)
        {
            my @fields = split(/:/, $line);
            my $debug_str = '';
 
            foreach $field (@fields)
            {
                my @prms = split(/=/, $field);
                if ($#prms == 1) { $params{$prms[0]} = $prms[1]; }
                $debug_str .= " $prms[0]=$params{$prms[0]};";
            }
        }
 
        # Далее уже происходит обработка полученных команд
        if ($params{'_cmd'} eq 'insert') ########################### INSERT cmd
        {
            my $qry = '';
            my $qry_valid = 0;
 
            if ($params{'type'} eq 'mbox') ######## Mailbox
            {
                $qry = "insert into persons(";
                my $dfields = '';
                my $fields_cnt = 0;
 
                while (($key, $value) = each(%params))
                {
                    if (defined($fnames{$key}) && $params{$key} ne '')
                    {
                        ...
                    }
                }
                ...
            }
            elsif ($params{'type'} eq 'alias')
            ...
        }
        ...
        # Далее подобным образом производится обработка остальных команд
        # и запись данных в БД
    }
}

Теперь только осталось сделать формирование команды при редактировании почтовых аккаунтов и ее отправку на описанный выше приемник. Поскольку web интерфейс я писал сам, сделать это не составило труда, вот например компонент для Yii Framework, который используется в afterSave модели:

/*
   To set properties throught config file main.php you have to add this lines to file
 
   'components'=>array(
       …
       'Userdir' => array(
          'class' => 'Userdir',
          'divId' => '0001',
          'userdirAddr' => 'admin@company.ru',
      ),
      …
   ),
 
 */

 
class Userdir extends CApplicationComponent
{
    private $_divId;
    private $_userdirAddr;
    private $_isLoaded = false;
    private $_transNames = array(
        'name' => 'name',
        'username' => 'eml',
        'post' => 'post',
        'phone_gor' => 'gor',
        ...
    );
    private $_cmdLine = '';
 
    // Working with properties
 
    public function getDivId()
    {
        return $this->_divId;
    }
 
    public function setDivId($value)
    {
        $this->_divId = $value;
    }
 
    public function getUserdirAddr()
    {
        return $this->_userdirAddr;
    }
 
    public function setUserdirAddr($value)
    {
        $this->_userdirAddr = $value;
    }
 
    // Public methods
 
    public function load($attrs, $cmd, $type = 'mbox')
    {
        $this->_cmdLine = '_cmd='.$cmd.':type='.$type;
 
        foreach ($attrs as $key => $value)
        {
            if (isset($this->_transNames[$key]))
            {
                $encodedValue = iconv('UTF-8', 'cp1251', $value);
                $this->_cmdLine .= ':'.$this->_transNames[$key].'='.$encodedValue;
            }
        }
 
        $this->_cmdLine .= "n";
        $this->_isLoaded = true;
    }
 
    public function send($params = null)
    {
        if ($this->_isLoaded)
        {
            return mail($this->_userdirAddr, $this->_divId, $this->_cmdLine);
        }
 
        return true;
    }
}

В afterSave пишем нечто вроде:

// Userdir replication
 
$userdir = Yii::app()->Userdir;
$userdir->setDivId($div_id = Domain::model()->find("domain='$this->domain'")->userdir_id);
 
$_userdirModes = array(
    'phones' => 'update',
    'update' => 'update',
    'create' => 'insert',
    'delete' => 'delete',
    'active' => 'update',
);
 
if (isset($_userdirModes[$this->scenario]))
{
    /* Edit */
    $userdir->load($this->attributes, $_userdirModes[$this->scenario]);
}
 
if (!$retCode = $userdir->send()) return $retCode;
 

И теперь у нас все автоматически обновляется.

Система внедрена и успешно работает уже третий год. Я написал немного сумбурно, старался сократить, если кому интересно — отвечу на любые вопросы.

Автор: ischerbin


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js