Пишем SSL туннель на python
Возникла задача: есть приложение под Windows, которое делает HTTPS-запросы к серверу и получает ответы. После обновления сервера приложение перестало работать. Выяснилось, что на сервере изменилась версия SSL (перешли с SSLv3 на TLSv1), а наше приложение умеет работать только по SSLv3. Приложение никто не поддерживает уже давно и менять, перекомпилировать, тестировать не хотелось. Решено было сделать прослойку между приложением и сервером, которая будет транслировать SSLv3 в TLSv1 и наоборот. Я поискал какой-нибудь прокси в интернете, но сходу не нашел (плохо искал). Решил сделать прокси на питоне. Я не профессионал в питоне, но мне показалось что для этой задачи он хорошо подходит, и интересно параллельно по изучать питон на примере реальной задачи.НачалоИтак, устанавливаем питон 3.4. Пишем скрипт, я для этого использовал блокнот. Для ssl-сокетов понадобится модуль ssl. Для, собственно, сокетов socket.
import ssl import socket Создаем сокет, слушающий клиента, т.к. это будет SSL-сервер, то придется создать для него само-подписанный сертификат, который он будет предоставлять клиенту. Для создания сертификата, я использовал утилиту openssl. Скачал утилиту отсюда indy.fulgan.com/SSL. Для создания сертификата потребуется конфиг для утилиты, пример можно взять здесь web.mit.edu/crypto/openssl.cnf. Кладем конфиг в папку на компе и устанавливаем путь к нему (далее все действия в командной строке): set OPENSSL_CONF=путь_к_файлу\openssl.cnf Генерим приватный ключ openssl genrsa -des3 -out server.key 1024 Попутно будет предложено ввести пароль к ключу и подтверждение пароля, вводим. Создаем запрос на сертификат openssl req -new -key server.key -out server.csr При генерации запроса нам нужно будет ввести пароль ключа и заполнить информацию о компании, городе, стране и т.д. Заполняем. Для того, чтобы можно было использовать ключ без пароля, копируем его и распароливаем copy server.key server.key.org openssl rsa -in server.key.org -out server.key Наконец, создаем самоподписанный сертификат openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt Для удобства кладем наш сертификат и ключ рядом со скриптом на питоне. Создаем сокет, который будет слушать клиента и ставим его слушать порт на который будет ходить наше приложение (далее код на питоне) sock = ssl.wrap_socket (socket.socket (), 'server.key', 'server.crt', True) sock.bind (('localhost', 43433)) sock.listen (10) Получаем входящее соединение и запрос от клиента conn, addr = sock.accept () data = conn.recv (1024) Далее нам нужно полученные данные отправить на сервер, которому они предназначались. Создаем для этого сокет и шлем в него данные serv = ssl.wrap_socket (socket.socket ()) serv.connect (('server_url', 443)) serv.send (data) Итак, запрос отправили, теперь нам надо получить респонз и отдать его нашему клиенту data = serv.recv (1024) conn.send (data) Ну все наш прокси готов, запускаем, кидаем запрос — не работает! Для того чтобы выяснить почему, добавим логирование.Логирование
Подключим модуль logging, настроим конфигурацию логирования и добавим логирование в интересные места
import logging
logging.basicConfig (filename = «proxy.log», level = logging.DEBUG, format = »%(asctime)s — %(message)s»)
logging.info («Ждем входящее соединение»); conn, addr = sock.accept () logging.info («Получаем запрос») data = conn.recv (1024) logging.info (data)
logging.info («Отправляем запрос на сервер») serv.send (data)
logging.info («Получаем ответ сервера») data = serv.recv (1024) logging.info (data)
logging.info («Отдаем ответ клиенту») client.send (resp) Чтение всех данных
Оказалось что данные клиент передает по блокам, т.е. мы прочитали не полный запрос. Потом выяснится, что сервер также отдает ответ по блокам. Усовершенствуем наш код чтобы читать запрос и ответ по блокам. Для этого создаем буфер, в который будем складывать весь запрос, устанавливаем сокету таймаут 0.1 с, который он будет ждать данные от входящего соединения и в цикле читаем и складываем в буфер данные. Если данных не будет, то получим исключение и выйдем из цикла
logging.info («Получаем запрос») data = conn.recv (1024) req = b'' conn.settimeout (0.1) while data: req += data try: data = conn.recv (1024) except socket.error: break logging.info (req) То же для чтения данных от сервера logging.info («Получаем ответ сервера») resp = b'' serv.settimeout (1) data = serv.recv (1024) while data: resp += data try: data = serv.recv (1024) except socket.error: break logging.info (resp) Меняем данные которые будем отправлять серверу и клиенту logging.info («Отправляем запрос на сервер») serv.send (req)
logging.info («Отдаем ответ клиенту») client.send (resp) Запускаем. Теперь работает, однако приходится запускать скрипт при каждом запросе к серверу, что не очень удобно.Обработка нескольких запросов
Усовершенствуем скрипт, после обработки запроса будем снова слушать сокет
while True: logging.info («Ждем входящее соединение»); conn, addr = sock.accept () logging.info («Получаем запрос») data = conn.recv (1024) req = b'' conn.settimeout (0.1) while data: req += data try: data = conn.recv (1024) except socket.error: break logging.info (req) logging.info («Отправляем запрос на сервер») serv.send (req) logging.info («Получаем ответ сервера») resp = b'' serv.settimeout (1) data = serv.recv (1024) while data: resp += data try: data = serv.recv (1024) except socket.error: break logging.info (resp) logging.info («Отдаем ответ клиенту») client.send (resp) Это будет работать, однако есть проблема — у нас бесконечный цикл из которого программа не может выйти нормальным образом. Для выхода можно использовать клавиатурное прерывание Ctrl+C и отправим запрос, после этого программа завершится по исключению KeyboardInterrupt.Остановка сервиса
Чтобы обеспечить более-менее нормальный выход, я решил передавать в сокет STOP, это будет управляющей командой завершения. Напишем обработчик для такой команды. Для этого нам потребуется модифицировать код чтения из клиентского сокета. Получаем первые четыре байта и если они будут STOP, прерываем цикл.
logging.info («Получаем запрос») data = conn.recv (4) if data == b’STOP': break Напишем функцию для остановки нашего прокси. В ней создадим сокет (ssl) и отправим STOP на наш прокси def stop (): logging.info («Останов»);
me = ssl.wrap_socket (socket.socket ()) me.connect (('localhost', 43433)) me.send (b’STOP') me.close () Для запуска команды STOP будем использовать параметр командной строки. Если передали строку stop в командной строке, то будем вызывать нашу функцию stop () (Помещаем этот код и функцию стоп в начало, после установки формата логирования). if len (sys.argv) > 1: if sys.argv[1] == «stop»: stop (); Теперь мы можем останавливать наш прокси тем же скриптом. Для того чтобы после остановки не выполнялся код запуска сервера, обернем основной код в функцию run, получится def run (): # сюда поместим код прокси-сервера описанный выше def stop (): # код приведен выше
if len (sys.argv) > 1: if sys.argv[1] == «stop»: stop (); else: print («Неизвестная комманда », sys.argv[1]) else: run () Заодно обработали случай с неправильной командой.Демонизация
Осталась проблема, при запуске нашего прокси приложение будет висеть в командной строке, на первый взгляд кажется, что оно зависло. Для решения этой проблемы сделаем демон. Т.к. у нас Windows, то демон тут делается запуском процесса без окна, этот код будет некроссплатформенным. Итак напишем функцию daemonize ()
import subprocess
def daemonize (): logging.info («Запуск демона»); subprocess.Popen («py proxy.py», creationflags=0×08000000, close_fds=True) Здесь creationflags=0×08000000, установка флага CREATE_NO_WINDOW для процесса. Будем запускать наш сервис в режиме демона если передали start в командной строке if len (sys.argv) > 1: if sys.argv[1] == «stop»: stop (); elif sys.argv[1] == «start»: daemonize (); else: print («Неизвестная комманда », sys.argv[1]) else: run () Теперь мы можем запускть наш сервис в режиме демона и останавливать.Многозадачность
Еще маленький штрих, добавим возможность обработки нескольких клиентов, для этого вынесем наш код работы с клиентом в отдельную функцию
def client_run (client, data): req = b''
logging.info («Получаем запрос») client.settimeout (0.1) while data: req += data try: data = client.recv (1024) except socket.error: break
logging.info (req)
serv = ssl.wrap_socket (socket.socket ()) serv.connect (('server_name', 443))
logging.info («Отправляем запрос на сервер») serv.send (req)
logging.info («Получаем ответ сервера») resp = b'' serv.settimeout (1) data = serv.recv (1024) while data: resp += data try: data = serv.recv (1024) except socket.error: break
logging.info (resp)
logging.info («Отдаем ответ клиенту») client.send (resp) А в главной функции будем запускать client_run в отдельном потоке, т.к. мы устанавливали socket.listen (10), то одновременно у нас может быть до 10 потоков def run (): logging.info («Старт главного потока»);
sock = ssl.wrap_socket (socket.socket (), 'server.key', 'server.crt', True) sock.bind (('localhost', 43433)) sock.listen (10)
while True: logging.info («Ждем входящее соединение»); conn, addr = sock.accept ()
data = conn.recv (4) if data == b’STOP': break
logging.info («Получен запрос») t = threading.Thread (target = client_run, args = (conn, data)) t.run () logging.info («Остановка») Теперь наш прокси-сервис готов.PS: Позже мне коллега подсказал, что для моей задачи можно использовать stunnel, и я решил поставить его, а скрипт выложить сюда, вдруг кому будет интересно. Конфиг для stunnel такой:
[client-in] sslVersion = SSLv3 accept = 127.0.0.1:43433 connect = 127.0.0.1:8080
[server-out] sslVersion = TLSv1 client = yes accept = 127.0.0.1:8080 connect = server_name:443 С stunnel также пришлось повозиться, т.к. на сервере были некорректные настройки и не проходила верификация SNI, заработало только с версией 4.36, т.к. там нет такой верификации.