Сергей Супрунов
Практикум Python: обрабатываем входящую электронную почту
«Хороший сисадмин – ленивый сисадмин». Если что-то можно сделать вручную, то почти наверняка это же можно и автоматизировать. Вопрос лишь в соотношении цена/качество, которым определяется целесообразность разработки очередного скрипта.
Если говорить об автоматизации процессов администрирования, то более точно будет говорить о соотношении «время на разработку/экономия времени в дальнейшем». Лично для меня наилучшее значение достигается при использовании языка Python.
В данной статье мы рассмотрим решение одной достаточно узконаправленной задачи. Тем не менее методы и приёмы, которые будут здесь продемонстрированы, я надеюсь, помогут вам решать проблемы гораздо более широкого спектра.
Постановка задачи
Итак, есть почтовый сервер: FreeBSD + Sendmail + ClamAV, остальное в данном случае не важно.
ClamAV выполняет проверку всей входящей корреспонденции на вирусы и, помимо всего прочего, отсылает уведомления об обнаруженных вирусах на адрес postmaster. Проблема заключается в том, что администратору (для определённости будем считать, что он получает почту на адрес admin, что достигается соответствующей настройкой в /etc/mail/aliases) приходится обрабатывать достаточно большое число таких уведомлений. В то же время, они позволяют следить за вирусной активностью на вверенном узле, так что отказываться от такой информации нежелательно.
Таким образом, требуется снизить нагрузку на администратора без потери накапливаемой в процессе работы статистики.
Анализ проблемы
Попробуем решить данную задачу путём формирования ежедневных «дайджестов», отражающих вирусную активность за прошедшие сутки. Интересовать нас будет количество обнаруженных вирусов с разбивкой по их названиям.
Очевидно, что наиболее простым и в принципе более правильным решением было бы отключить уведомления на postmaster и просто обрабатывать log-файлы, формируемые в процессе работы.
Однако мы пойдём другим путём, чтобы рассмотреть на практике один из достаточно полезных приёмов: попробуем обрабатывать всю поступающую на postmaster почту, что называется, «на лету». Поскольку на данный адрес идёт почта не только от ClamAV, нам нужно перехватывать только интересующие нас уведомления, а всё остальное без изменений пересылать администратору (в нашем примере, на адрес admin).
Идеи по реализации
Наиболее удобным выглядит перенаправление почты на вход скрипта-обработчика путём создания канала (pipe), что можно реализовать с помощью псевдонимов (aliases). Например, если в файле /etc/mail/aliases добавить такую строку:
postmaster: “| /usr/local/scripts/maildigest/maildigest.py”
то вся почта, поступающая на указанный адрес, будет передаваться на стандартный вход (STDIN) сценария maildigest.py. На время тестирования можно будет не переправлять, а дублировать почту на скрипт, оставив в качестве получателя и прежнего пользователя (чтобы избежать потери почты):
postmaster: admin, “| /usr/local/scripts/maildigest/maildigest.py”
Получая очередное сообщение на обработку, сценарий должен будет проверить, не является ли оно уведомлением от ClamAV (будем контролировать два параметра: тему сообщения и тег заголовка «Auto-Submitted»). Если является, то обрабатываем его и записываем результат в некоторый файл (о формате пока не думаем). Если же это какое-то другое письмо, то положим его сразу в почтовый ящик пользователя admin. При необходимости изменить имя получателя его можно будет подправить непосредственно в коде сценария (не совсем «академическое» решение, но оно позволяет упростить код, не разбрасываясь на обработку параметров).
Небольшое исследование
Сначала давайте посмотрим, в каком виде сообщения поступают на вход сценария, а заодно проверим, с какими правами наш сценарий исполняется (эта информация нам понадобится в дальнейшем для решения проблемы передачи «прочей» почты пользователю admin).
Для этого напишем небольшой сценарий (как определено в aliases, сохраним его под именем maildigest.py, не забыв установить права на исполнение):
Листинг 1. Первый эксперимент
#!/usr/local/bin/python
import os, sys
mail = sys.stdin.read()
fd = open('/var/scripts/maildigest/mail.txt', 'w')
fd.write(mail)
fd.write(os.popen('id').read())
fd.close()
Здесь мы всё, что поступает на стандартный вход (дескриптор определён в sys.stdin), записываем в файл mail.txt. Сюда же добавляем строчку, возвращающую идентификатор текущего пользователя, для чего воспользуемся функцией os.popen(), которая создаёт канал между сценарием и системной командой (в нашем случае это команда id). На первых порах нужно разрешить любому пользователю создавать файлы в каталоге /var/scripts/maildigest.
В итоге получим примерно следующее (часть полей заголовка за ненадобностью не показана):
From clamav@mydomain.ru Fri Feb 17 10:00:07 2006
[. . .]
Date: Fri, 17 Feb 2006 09:53:41 +0300 (MSK)
Message-Id: <200602170653.k1H6rfHX047203@mydomain.ru>
From: MAILER-DAEMON@mydomain.ru
To: postmaster@mydomain.ru
Auto-Submitted: auto-submitted (antivirus notify)
Subject: Virus intercepted
X-Virus-Scanned: ClamAV 0.88/1291/Thu Feb 16 23:15:09 2006 on mydomain.ru
X-Virus-Status: Clean
[. . .]
The message k1H6rBq7047195 sent from to
contained Worm.SomeFool.P and has not been delivered.
uid=26(mailnull) gid=26(mailnull) groups=26(mailnull)
|
Вопросы доставки
Как видите, сообщение мы получаем в том виде, в каком оно будет в дальнейшем помещено в почтовый ящик пользователя. Обработать его проблем не составит – задействуем модуль rfc822, содержащий методы для разбора заголовков. А вот о чём придётся подумать, так это о том, как положить «транзитное» письмо в ящик пользователю. Можно, конечно, воспользоваться протоколом SMTP, но при наличии уже сформированного сообщения формировать его заново выглядит не очень разумным. К тому же есть риск зациклить обработку письма. Попробуем воспользоваться услугами локального агента доставки (LDA).
Хорошо бы просто отдавать сообщение на вход mail.local (LDA, используемый во FreeBSD по умолчанию). Здесь мы упираемся в то, что для выполнения своей работы mail.local должен запускаться с правами пользователя root.
Обойти это можно, установив на mail.local бит suid, однако поскольку такие права нужны нам для решения частной задачи, то более правильно будет создать копию агента доставки с нужными правами, а оригинальный файл не трогать:
# cd /usr/local/scripts/maildigest/
# cp /usr/libexec/mail.local mail.local.suid
# chmod 4555 mail.local.suid
Проведём ещё один эксперимент:
Листинг 2. Второй эксперимент
#!/usr/local/bin/python
import os
prefix_bin = '/usr/local/scripts/maildigest/'
prefix_var = '/var/scripts/maildigest/'
lda_command = prefix_bin + 'mail.local.suid admin'
mail = open(prefix_var + 'mail.txt', 'r').read()
os.popen(lda_command).write(mail))
Напомню, что все тестовые сценарии мы сохраняем под именем maildigest.py, чтобы не вносить каждый раз изменения в /etc/mail/aliases.
Здесь с помощью той же функции popen() создается канал с утилитой mail.local.suid, на вход которой передается текст сообщения, сохранённого в файле в результате предыдущего эксперимента. Пользователя-получателя указываем явно (admin). В принципе этот сценарий можно выполнить и непосредственно из командной строки, но лучше использовать тот же способ запуска через aliases, чтобы лишний раз убедиться в отсутствии проблем с правами доступа и переменными окружения.
Отправив тестовое сообщение на postmaster, убеждаемся, что доставка выполняется нормально, естественно, при условии, что запускался предыдущий тест, в результате которого должен был сформироваться файл, который в данном случае и используется. Значит, так и будем поступать в дальнейшем.
Сбор статистики и формирование «дайджеста»
Информация об обнаруженном вирусе (его название) содержится в последней строчке уведомления (если быть точнее, то в предпоследней, а последняя – пустая). Если эту строку разбить по пробелам, то нужное нам имя получим во втором поле.
Учитывая, что на моём сервере больших нагрузок не предвидится, для хранения результата я выбрал формат DBM.
В стандартную поставку Python включён модуль anydbm, который самостоятельно определяет, какая именно реализация DBM используется в вашей системе, так что об этом нам заботиться не придётся. Данные в этом формате хранятся в виде пар «имя – значение». Единственный его недостаток в нашем случае – это то, что он позволяет хранить только текстовые данные, т.е. придётся в процессе работы выполнять преобразования сохранённого значения, отражающего количество обнаруженных вирусов данного типа, из строки в число и обратно.
Для удобства будем хранить информацию посуточно, для чего имя db-файла будет формироваться с учётом текущей даты (см. код сценария ниже).
Наконец, при смене даты нам нужно будет формировать и отправлять сводный отчёт за прошедшие сутки. Поскольку отчёт предназначается локальному пользователю, то проще всего будет сформировать «вручную» сообщение с нужными заголовками и воспользоваться тем же LDA для его доставки.
Реализация
Итак, приступим к разработке сценария. Чтобы сохранить целостность восприятия, полностью приведу прокомментированный текст скрипта, а ниже дам некоторые пояснения.
Листинг 3. Сценарий maildigest.py
#!/usr/local/bin/python
# -*- coding: koi8-r -*-
# Импортирование нужных модулей
import os, sys, rfc822, anydbm
from StringIO import StringIO
from time import ctime, strftime
# «Родительские» каталоги для размещения файлов
prefix_bin = '/usr/local/scripts/maildigest/'
prefix_var = '/var/scripts/maildigest/'
# Команда доставки сообщения --
lda_command = prefix_bin + 'mail.local.suid admin'
# Текущее имя db-файла (зависит от текущей даты)
mdfile = prefix_var + 'md' + strftime('%Y%m%d')
# Файл хранит имя следующей для обработанной базы
next = prefix_var + '.next'
# Функция формирования и отправки «дайджеста»
def send_digest(fn=mdfile):
# Здесь ошибку не проверяем, поскольку всё равно работу сценария придётся прерывать
lda = os.popen(lda_command, 'w').write
# Формируем необходимые заголовки
lda('From: maildigest\r\n')
lda('To: admin@mydomain.ru\r\n')
lda('Date: %s\r\n' % ctime())
lda('Subject: Virus digest\r\n')
lda('\r\n')
# Фомируем отчёт
lda('Вирусная активность за %s\r\n\r\n' % fn[-8:])
lda('%-30s | %3s\r\n' % ('Имя вируса', 'К-во'))
lda('-' * 38 + '\r\n')
try:
d = anydbm.open(fn)
total = 0
for i in d.keys():
lda('%-30s | %3s\r\n' % (i, d[i]))
total += int(d[i])
lda('-' * 38 + '\r\n')
lda('%-30s | %3d\r\n’ % ('ИТОГО', total))
# Записываем в .next следующую базу
open(next, 'w').write(mdfile)
except:
lda('Ошибка формирования дайджеста для %s\r\n' % fn)
# Считываем поступившее сообщение...
mail = sys.stdin.read()
# ...и разбираем его «по косточкам»
message = rfc822.Message(StringIO(mail))
# Если в заголовке есть указанные поля с указанными значениями, то считываем имя вируса
# и увеличиваем для него счётчик
if (message.getheader('Subject') == 'Virus intercepted' and
message.getheader('Auto-Submitted') ==
'auto-submitted (antivirus notify)'):
try:
# Первый «сплит» выделяет предпоследнюю строку (последняя - пустая),
# а второй - второе поле строки
virusname = mail.split('\n')[-2].split(' ')[1]
except:
virusname = 'Format error'
stat = anydbm.open(mdfile, 'c')
if stat.has_key(virusname):
stat[virusname] = str(int(stat[virusname]) + 1)
else:
stat[virusname] = '1'
else:
os.popen(lda_command, 'w').write(mail)
# Если в next-файле записано имя «прошлой» базы, то отсылаем дайджест
if os.path.isfile(next):
mdf = open(next, 'r').read(len(prefix_var) + 10)
if mdfile != mdf:
send_digest(mdf)
else:
open(next, 'w').write(mdfile)
На всякий случай напомню, что блоки кода в Python задаются с помощью отступов. В принципе всё должно быть понятно. Пояснения требует разве что использование переменной lda в функции send_digest(). В первой строке мы присваиваем этой переменной ссылку на метод write(), который применяется к каналу, созданному для команды, определённой в lda_command. В дальнейшем мы можем использовать функцию lda() как замену конструкции os.popen(lda_command, 'w').write().
Ещё следует указать, что скобки позволяют обойти жёсткие требования соблюдать отступ, чем мы и воспользовались в трехстрочной конструкции if, где мы проверяем наличие в письме нужных нам заголовков.
Теперь вместо десятков уведомлений администратор будет получать одно письмо в день, отражающее вирусную активность на почтовом сервере за истекшие сутки (см. рис. 1).
Рисунок 1. Примерно так выглядят формируемые отчёты
Нет предела фантазии
Насладившись работой нашего сценария, поразмышляем о том, что ещё полезного можно сделать подобным способом.
Во-первых, можно реализовать автоматический разбор входящей почты (когда сообщения со словом «Договор» в теме направляются в абонентский отдел, а со словом «Счёт» – в бухгалтерию). Заодно можно организовать функцию автоматического ответа, когда отправитель сообщения будет получать уведомление, что его письмо получено и передано на обработку Иванову Ивану Ивановичу (ещё одно последствие борьбы со спамом, когда приходится подтверждать доставку почти каждого важного письма, чтобы убедиться, что оно не попало под «жернов» одного из фильтров).
Во-вторых, обработка входящих сообщений позволяет при определённой доле осторожности и «разумности» использовать электронную почту для управления каким-либо сервисом путём отправки команд в теле или теме сообщения на определённый адрес (только не используйте этот механизм для создания учётных записей и перезагрузки сервера!).
Наконец, можно пропускать входящую корреспонденцию через собственные фильтры или собирать ту или иную статистику. В общем, полёт вашей фантазии ничем не ограничен.