Pull to refresh

Пишем SSL туннель на python

Reading time 6 min
Views 53K
Возникла задача: есть приложение под 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=0x08000000, close_fds=True)

Здесь creationflags=0x08000000, установка флага 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, т.к. там нет такой верификации.

Исходники на github github.com/sesk/py_proxy
Tags:
Hubs:
+14
Comments 4
Comments Comments 4

Articles