воскресенье, 26 марта 2017 г.

Таймаут DNS в OpenNTPd

Как-то на работе настраивал сервер для отправки в филиал. Всё настроил, начал уже отключать от сервера провода, вдруг решил ещё раз проверить какой-то файл конфигурации. Включил сервер и обнаружил, что загрузка замерла.

Стал разбираться, что же не так. Как оказалось, загрузка остановилась на этапе запуска OpenNTPd. Заметил, что сервер не был подключен к сети. Воткнул сеть - сервер стал загружаться дальше. В интернете нашёл описание этой ошибки в OpenNTPd: net-misc/openntpd - failed dns results in extended startup delay when -s option in use

Проблема заключается в том, что у OpenNTPd предусмотрен таймаут для NTP-серверов, но не предусмотрен таймаут для DNS-серверов, поэтому демон продолжает в бесконечном цикле выполнять DNS-запросы, пытаясь узнать имена NTP-серверов.

Проблему можно решить одним из способов:
  • Убрать из настроек опцию -s,
  • Прописать в конфигурацию IP-адреса NTP-серверов, а не доменные имена,
  • Пропатчить пакет патчем из этого отчёта об ошибке.
Я решил тогда пропатчить пакет. Потом я много раз пользовался этим пакетом, но описать его сборку всё забывал. Вот сейчас решил всё-таки задокументировать эту процедуру, хотя в новых версиях Debian она станет больше не нужной, т.к. в новых версиях OpenNTPd ошибка уже исправлена.

Для начала отредактируем файл /etc/apt/sources.list и добавим строчки с репозиториями deb-src с исходными текстами:
deb http://mirror.ufanet.ru/debian/ wheezy main contrib non-free
deb http://mirror.ufanet.ru/debian/ wheezy-updates main contrib non-free
deb http://mirror.ufanet.ru/debian/ wheezy-proposed-updates main contrib non-free
deb http://mirror.ufanet.ru/debian-security wheezy/updates main contrib non-free

deb-src http://mirror.ufanet.ru/debian/ wheezy main contrib non-free
deb-src http://mirror.ufanet.ru/debian/ wheezy-updates main contrib non-free
deb-src http://mirror.ufanet.ru/debian/ wheezy-proposed-updates main contrib non-free
deb-src http://mirror.ufanet.ru/debian-security wheezy/updates main contrib non-free
Установим инструменты, необходимые для сборки пакета:
# apt-get install dpkg-dev devscripts fakeroot quilt
Установим пакет с исходными текстами openntpd:
# apt-get source openntpd
Скачаем подготовленный мной патч:
# wget http://stupin.su/files/openntpd-20080406p-dns-timeout.patch
Перейдём в каталог с исходными текстами пакета:
# cd openntpd-20080406p
Наложим скачанный патч:
# patch -Np0 < ../openntpd-20080406p-dns-timeout.patch
Установим пакеты, необходимые для сборки пакета openntpd:
# apt-get build-dep openntpd
Оформим изменения, сделанные в исходных текстах, в виде патча:
# dpkg-source --commit
В ответ на запрос имени заплатки введём dns-timeout.

Содержимое заголовка заплатки:
Description: DNS timeout added
 Added patch from https://bugs.gentoo.org/show_bug.cgi?id=493358
 .
 openntpd (20080406p-4) unstable; urgency=low
Author: Vladimir Stupin <vladimir@stupin.su>
Last-Update: <2017-02-09>

--- openntpd-20080406p.orig/ntpd.c
+++ openntpd-20080406p/ntpd.c
Теперь опишем сделанные изменения в журнале изменений:
# dch -i
Свежая запись в журнале будет выглядеть следующим образом:
openntpd (20080406p-4.1) UNRELEASED; urgency=low

  * DNS timeout added

 -- Vladimir Stupin <vladimir@stupin.su>  Thu, 09 Mar 2017 22:11:37 +0500
Соберём новый пакет с исходными текстами и двоичный пакет:
# dpkg-buildpackage -us -uc -rfakeroot
Теперь можно перейти на уровень выше и установить собранный пакет:
# cd ..
# dpkg -i openntpd_20080406p-4.1_amd64.deb
С новым пакетом пауза в процессе загрузки составляет 1 минуту 40 секунд, что вполне приемлемо, т.к. сервер в конце концов всё-таки загружается, а на фоне всего процесса загрузки эта дополнительная пауза почти не заметна.

воскресенье, 19 марта 2017 г.

Смена темы письма в Postfix

В одной из прошлых заметок Postfix как локальный SMTP-ретранслятор я описал настройки почтового сервера, выступающего в роли почтового клиента для другого сервера. Эту конфигурацию я использую для отправки служебных писем со своих серверов.

Часто письма с разных серверов приходят от одного и того же отправителя, с одной и той же темой. Если ни в теме письма, ни в его теле явным образом не упоминается сервер, с которого было отправлено письмо, то обычно приходится смотреть заголовки письма, чтобы определить, с какого из серверов оно на самом деле было отправлено.

Чтобы решить эту проблему и облегчить себе работу, я решил настроить все свои серверы так, чтобы Postfix добавлял в тему отправляемого письма имя сервера.

Сделать это просто. Для начала нужно установить пакет postfix-pcre:
# apt-get install postfix-pcre
Далее, добавим в файл /etc/postfix/main.cf одну строчку:
header_checks = pcre:/etc/postfix/rewrite_subject
Теперь нужно создать файл /etc/postfix/rewrite_subject и поместить в него правило, которое будет добавлять в тему письма дополнительный текст с именем сервера. Например, вот так:
/^Subject: (.*)$/ REPLACE Subject: $1 (from server.domain.tld)
Текст "/^Subject: (.*)$/" является регулярным выражением в стиле Perl. Это регулярное выражение будет совпадать со строкой заголовка, начинающейся с текста "Subject: ". Текст "(.*)" совпадает с любой темой письма и будет запомнен в переменной $1.

Действие "REPLACE" говорит о том, что нужно произвести замену текста.

Строка "Subject: $1 (from server.domain.tld)" содержит в текст, который заменит совпавшую строчку. При этом вместо переменной $1 будет подставлено её значение. Если имя переменной не отделено от остального текста пробельными символами, тогда её имя следует записывать в виде ${1}

Итак, осталось перезапустить почтовый сервер:
# /etc/init.d/postfix restart
Проверить, добавляется ли текст к теме письма можно следующим образом:
# mail -s test box@mailserver.tld
test
.
При этом в почтовом ящике должно появиться письмо с темой "test (from server.domain.tld)".

воскресенье, 12 марта 2017 г.

Использование urllib2 в Python

Как известно, Python поставляется с "батарейками" - в комплекте идёт масса различных модулей. Одним из таких модулей является модуль urllib2, позволяющий выполнять веб-запросы. К сожалению, этот модуль не блещет наглядностью, потому что для выполнения запроса зачастую требуется соединить между собой несколько объектов. Это может быть привычным по идеологии для программистов, пишущих на Java, но вот у большинства программистов, использующих скриптовые языки, этот подход напрочь отбивает всё желание пользоваться этим модулем. В результате я наблюдаю, например, что коллеги для выполнения веб-запросов часто пользуются сторонними, более дружелюбными модулями. Трое из них пользовались pycurl, а один - requests. Кстати, девиз модуля requests звучит как "Python HTTP для людей", что как бы намекает на то, что по мнению авторов модуля requests модуль urllib2 сделан для инопланетян. Пожалуй, в этом есть доля истины.

Не знаю почему, но я старался пользоваться родным модулем из Python. Возможно потому, что его использование позволяет обойтись без дополнительных зависимостей. Возможно также, что я пытался пользоваться родным модулем, потому что ранее в скриптах на Perl пользовался родным для него модулем LWP и у меня с ним не возникало никаких проблем. Я ожидал, что с родным модулем из Python тоже не должно возникнуть никаких проблем. Мне было трудно, но, к счастью, разбираться с модулем мне пришлось поэтапно, так что со временем в моих скриптах и программах набралось достаточное количество примеров на самые разные случаи с обработкой всех встречавшихся мне исключений. Я решил собрать все эти примеры в одном месте и поделиться ими в этой статье.

1. GET-запрос

Начнём с самого простого - с выполнения GET-запроса. Нужно просто скачать страницу.
import warnings, urllib, urllib2
warnings.filterwarnings('ignore', category=UserWarning, module='urllib2')

def http_get(str_url, dict_params, dict_headers, float_timeout):
    query = urllib.urlencode(dict_params)
    req = urllib2.Request(str_url + '?' + query, headers=dict_headers, timeout=float_timeout)
    
    opener = urllib2.build_opener()
    
    try:
        res = opener.open(req)
    except urllib2.HTTPError as e:
        res = e
    except urllib2.URLError as e:
        return None, None
    except socket.timeout:
        return None, None
    except ssl.SSLError:
        return None, None
  
    return res.code, res.read()
Здесь можно увидеть функцию http_get, которая принимает параметры:
  • str_url - строка с URL страницы, которую нужно скачать,
  • dict_params - словарь с параметрами и их значениями, из которых будет сформирована строка запроса. Эта строка запроса потом будет добавлена к строке URL после знака вопроса. Ключи словаря являются именами параметров, а значения - соответственно значениями,
  • dict_headers - словарь с дополнительными заголовками, которые будут добавлены в GET-запрос. Ключи словаря являются именами заголовков, а значения - их значениями,
  • float_timeout - число с плавающей запятой, задающее таймаут ожидания запроса.
Функция возвращает кортеж из двух значений: первое значение содержит код ошибки от веб-сервера, а второе значение содержит тело ответа. Если в строке URL содержится ошибка, не отвечает DNS-сервер, DNS-сервер сообщает об ошибке поиска имени или если соединиться с веб-сервером не удалось, будет возвращён кортеж с двумя значениями None. Если вам нужно различать ошибки разного рода, переделайте обработку ошибок под свои нужды.

Если вам нужно узнать заголовки из ответа, то имейте в виду, что объект res принадлежит классу httplib.HTTPResponse. У объектов этого класса есть методы getheader и getheaders. Метод getheader вернёт значение заголовка с указанным именем или значение по умолчанию. Метод getheaders вернёт список кортежей с именами заголовков и их значениями. Например, словарь из заголовков и их значений (исключая заголовки с несколькими значениями) можно было бы получить следующим образом:
headers = dict(res.getheaders())

2. POST-запрос enctype=application/x-www-form-urlencoded

Можно сказать, что это "обычный" POST-запрос. В таких POST-запросах применяется кодирование параметров, аналогичное тому, которое используется для кодирования параметров в GET-запросах. В случае с GET-запросами строка с параметрами добавляется после вопросительного знака к строке URL запрашиваемого ресурса. В случае с POST-запросом эта строка с параметрами помещается в тело запроса. В запросах такого рода нельзя передать на сервер файл.
import warnings, urllib, urllib2
warnings.filterwarnings('ignore', category=UserWarning, module='urllib2')

def http_post(str_url, dict_params, dict_headers, float_timeout):
    query = urllib.urlencode(dict_params)
    dict_headers['Content-type'] = 'application/x-www-form-urlencoded'
    req = urllib2.Request(str_url, data=query, headers=dict_headers, timeout=float_timeout)
    
    opener = urllib2.build_opener()
    
    try:
        res = opener.open(req)
    except urllib2.HTTPError as e:
        res = e
    except urllib2.URLError as e:
        return None, None
    except socket.timeout:
        return None, None
    except ssl.SSLError:
        return None, None
  
    return res.code, res.read()
Функция http_post по входным и выходным параметрам полностью аналогична функции http_get, только выполняет POST-запрос.

3. POST-запрос enctype=multipart/form-data

Если в запросе нужно отправить много данных, то они могут оказаться слишком большими, чтобы уместиться в одной строке. В таких случаях, например если нужно приложить файл, в веб-приложениях используются формы с типом "multipart/form-data". Для отправки подобных запросов переделаем предыдущую функцию, воспользовавшись модулем poster.
import warnings, urllib2
warnings.filterwarnings('ignore', category=UserWarning, module='urllib2')
from poster.encode import multipart_encode

def http_post_multipart(str_url, dict_params, dict_headers, float_timeout):
    body, headers = multipart_encode(dict_params)
    dict_headers.update(headers)
    req = urllib2.Request(str_url, data=body, headers=dict_headers, timeout=float_timeout)
    
    opener = urllib2.build_opener()
    
    try:
        res = opener.open(req)
    except urllib2.HTTPError as e:
        res = e
    except urllib2.URLError as e:
        return None, None
    except socket.timeout:
        return None, None
    except ssl.SSLError:
        return None, None
  
    return res.code, res.read()
Функция http_post_multipart по входным и выходным параметрам полностью аналогична функциям http_get и http_post, только выполняет POST-запрос, закодировав параметры способом, пригодным для передачи данных большого объёма, в том числе - файлов.

4. GET-запрос с аутентификацией

Продемонстрирую аутентификацию на примере с GET-запросом:
import warnings, urllib, urllib2
warnings.filterwarnings('ignore', category=UserWarning, module='urllib2')

def http_get_auth(str_url, dict_params, dict_headers, float_timeout, str_user, str_password):
    query = urllib.urlencode(dict_params)
    req = urllib2.Request(str_url + '?' + query, headers=dict_headers, timeout=float_timeout)
    
    passman = urllib2.HTTPPasswordMgrWithDefaultRealm()
    passman.add_password(None, str_url, str_user, str_password)
    authhandler = urllib2.HTTPBasicAuthHandler(passman)
    
    opener = urllib2.build_opener(authhandler)
    
    try:
        res = opener.open(req)
    except urllib2.HTTPError as e:
        res = e
    except urllib2.URLError as e:
        return None, None
    except socket.timeout:
        return None, None
    except ssl.SSLError:
        return None, None
  
    return res.code, res.read()
Как видно, функция http_get_auth по выходным параметрам совпадает со всеми предыдущими функциями, но среди входных параметров появилось два новых:
  • str_user - строка, содержащая имя пользователя,
  • str_password - строка, содержащая пароль пользователя.
Внутри функции появились три новые строчки, выделенные жирным шрифтом. Эти строчки создают менеджер паролей и дополнительный обработчик ответа от веб-сервера. В четвёртой выделенной строчке создаётся объект, который будет выполнять запрос с использованием этого дополнительного обработчика. Задача обработчика простая - если при запросе без аутентификации будет возвращён код ошибки 401, то в запрос будет добавлен дополнительный заголовок, содержащий имя пользователя и его пароль, после чего запрос будет повторён.

Менеджер паролей может предоставлять обработчику разные пароли, в зависимости от области доступа (часто называемой realm'ом), которую сообщит веб-сервер или в зависимости от URL запрашиваемого ресурса.

5. GET-запрос с безусловной аутентификацией

Обработчик HTTPBasicAuthHandler устроен таким образом, что сначала всегда делает запрос без передачи логина и пароля. И только если получен ответ с кодом 401, уже пытается выполнить запрос с аутентификацией.

При попытке выполнить запрос к API, реализованному на основе библиотеки swagger, возникают некоторые трудности с аутентификацией. При обращении к API без аутентификации, API возвращает HTML-справку по использованию API с кодом ответа 403, поэтому дополнительных попыток получить страницу с аутентификацией не предпринимается.

Чтобы исправить эту ситуацию, я воспользовался ответом, подсмотренным здесь: does urllib2 support preemptive authentication authentication?
import warnings, urllib, urllib2, base64
warnings.filterwarnings('ignore', category=UserWarning, module='urllib2')

class PreemptiveBasicAuthHandler(urllib2.HTTPBasicAuthHandler):
    '''Preemptive basic auth.

    Instead of waiting for a 403 to then retry with the credentials,
    send the credentials if the url is handled by the password manager.
    Note: please use realm=None when calling add_password.'''
    def http_request(self, req):
        url = req.get_full_url()
        realm = None
        # this is very similar to the code from retry_http_basic_auth()
        # but returns a request object.
        user, pw = self.passwd.find_user_password(realm, url)
        if pw:
            raw = "%s:%s" % (user, pw)
            auth = 'Basic %s' % base64.b64encode(raw).strip()
            req.add_unredirected_header(self.auth_header, auth)
        return req

    https_request = http_request

def http_get_preemptive_auth(str_url, dict_params, dict_headers, float_timeout, str_user, str_password):
    query = urllib.urlencode(dict_params)
    req = urllib2.Request(str_url + '?' + query, headers=dict_headers, timeout=float_timeout)
    
    passman = urllib2.HTTPPasswordMgrWithDefaultRealm()
    passman.add_password(None, str_url, str_user, str_password)
    authhandler = PreemptiveBasicAuthHandler(passman)
    
    opener = urllib2.build_opener(authhandler)
    
    try:
        res = opener.open(req)
    except urllib2.HTTPError as e:
        res = e
    except urllib2.URLError as e:
        return None, None
    except socket.timeout:
        return None, None
    except ssl.SSLError:
        return None, None
  
    return res.code, res.read()
Функция http_get_preemptive_auth по входным и выходным параметрам полностью аналогична функции http_get_auth, но в ней используется другой дополнительный обработчик - PreemptiveBasicAuthHandler, который отличается от обработчика HTTPBasicAuthHandler тем, что не выполняет запрос без аутентификации, ожидая получить ошибку 401, а сразу отправляет логин и пароль, соответствующие запрашиваемому URL.

6. GET-запрос через прокси

Смотрим пример:
import warnings, urllib, urllib2
warnings.filterwarnings('ignore', category=UserWarning, module='urllib2')

def http_get_proxy(str_url, dict_params, dict_headers, float_timeout, str_proxy):
    query = urllib.urlencode(dict_params)
    req = urllib2.Request(url + '?' + query, headers=dict_headers, timeout=float_timeout)
    
    proxyhandler = urllib2.ProxyHandler(str_proxy)

    opener = urllib2.build_opener(proxyhandler)
    
    try:
        res = opener.open(req)
    except urllib2.HTTPError as e:
        res = e
    except urllib2.URLError as e:
        return None, None
    except socket.timeout:
        return None, None
    except ssl.SSLError:
        return None, None
  
    return res.code, res.read()
Функция http_get_proxy по выходным параметрам полностью соответствует функции http_get, а среди входных параметров имеется один дополнительный:
  • str_proxy - строка с адресом веб-прокси. Имеет вид http://proxy.domain.tld:3128
В этом примере используется дополнительный обработчик запросов ProxyHandler, который позволяет отправлять запросы через веб-прокси. На самом деле, этому обработчику можно передать не строку с одним веб-прокси, а передать словарь, в котором для разных протоколов будут указаны разные прокси:
{
 'http': 'http://proxy-http.domain.tld:3128',
 'https': 'http://proxy-https.domain.tld:3128',
 'ftp': 'http://proxy-ftp.domain.tld:3128'
}

7. Использование нескольких обработчиков запросов одновременно

Когда в запросе нужно использовать несколько обработчиков одновременно, то можно растеряться, т.к. в примерах выше каждый раз используется только один обработчик для аутентификации или для прокси. Но использовать несколько обработчиков совсем не трудно. Это можно сделать, указав их через запятую, вот так:
opener = urllib2.build_opener(authhandler, proxyhandler)
Но и в этом случае можно растеряться, если список обработчиков нужно формировать динамически, так что каждый конкретный обработчик может отсутствовать. Тут тоже всё просто. Можно добавлять обработчики в массив, а затем использовать этот массив как список аргументов:
# В начале список обработчиков пуст
handlers = []

# Если нужна аутентификация, добавляем обработчик в список
if ...:
    ...
    authhandler = ...
    handlers.append(authhandler)

# Если запрос нужно выполнить через прокси, добавляем обработчик в список
if ...:
    ...
    proxyhandler = ...
    handlers.append(proxyhandler)

# Выполняем запрос, используя все обработчики из списка
opener = urllib2.build_opener(*handlers)
Если нужно пройти аутентификацию и на прокси и на веб-ресурсе, то для аутентификации на прокси можно использовать обработчик ProxyBasicAuthHandler, так что всего будет использоваться аж сразу три обработчика запросов.

Этих примеров достаточно для того, чтобы научиться пользоваться модулем urllib2 и понимать, в каком направлении надо копать, чтобы сделать что-то такое, что здесь не описано. Если у вас есть чем дополнить эту статью, прошу написать мне об этом в комментариях.