Резюме
- Python-прокси-серверу нужны два разных кодовых пути, а не один. Обычные HTTP-запросы поступают в виде полной строки запроса, которую сервер может переслать напрямую; HTTPS-запросы приходят как запрос
CONNECT, который сервер должен туннелировать непрозрачно, а не разбирать. asyncioобрабатывает множество одновременных подключений без ограничения на поток за подключение. Пять одновременных запросов через прокси, созданный в этом руководстве, завершились менее чем за 40 мс в общей сложности, при этом ни один запрос не блокировал другой.- Метод
CONNECTявляется тем, что делает работу HTTPS через прокси возможной, и это шаг, который большинство обучающих материалов с нуля пропускают или не проверяют — это руководство реализует его и проверяет на реальном сайте HTTPS. - Добавление поддержки
Proxy-Authorization: Basicпревращает открытый реле в аутентифицированный, и разница поддается проверке: неверные или отсутствующие учетные данные возвращают реальный код407, правильные —200. - Самостоятельно размещенный прокси имеет ровно столько IP-адресов для выхода, сколько у вас машин для его запуска — обычно один. Это устраивает для локальной разработки или реле в одном регионе, но это то, с чем вы сталкиваетесь, если цель — распределить запросы по множеству IP-адресов.
- Ничто из этого не расшифровывает и не инспектирует HTTPS-трафик. Туннель
CONNECTпересылает зашифрованные байты как есть, что является правильным, менее удивительным поведением для личного форвард-прокси и избегает необходимости управлять сертификатами TLS для перехвата.
Введение: написание собственного форвард-прокси вместо простого использования
Форвард-прокси находится между клиентом и интернетом, принимая запросы клиента и выполняя их от своего имени. Создание одного из них на Python — это действительно другое задание, чем использование прокси из скрипта на Python — указание requests на шлюз другого человека — это всего несколько строк; корректная переработка как обычного HTTP, так и HTTPS, обработка нескольких клиентов одновременно и отказ от несанкционированного использования — это то, на чем останавливаются большинство учебных пособий с нуля. Это руководство создает тот сервер с нуля на стандартной библиотеке Python, проверяет каждый путь на реальной цели и откровенно говорит о том, где ограничения самоуправляемого реле проявляются на практике.
Создание здесь использует только asyncio, который идет в комплекте с Python 3.7+ — никаких дополнительных пакетов не требуется для основного сервера. Все проверяется, запуская его: каждый блок кода ниже был выполнен либо против локального тестового сервера, либо против реального HTTPS-сайта, при этом точные команды и результаты показаны рядом с кодом, а не просто описаны. Самостоятельно размещенный прокси, как этот, также является общим строительным блоком для тестирования условий сети и рабочих процессов контроля качества, где команде нужно полное представление и контроль над тем, что действительно делает запрос перед тем, как он покинет сеть.
Установка: что вам нужно (и что не нужно)
Сервер прокси сам по себе не имеет внешних зависимостей — asyncio, base64 и sys являются частью стандартной библиотеки на любой установке Python 3.7+. Две вещи используются только для тестирования, а не для самого прокси:
curl, чтобы управлять прокси из командной строки с флагом-x.- Библиотека
requests(pip install requests), чтобы подтвердить, что прокси также работает так, как ожидает обычный HTTP-клиент на Python — точная комбинация, подразумеваемая ключевыми словами "python proxy server", которая охватывает как написание прокси на Python, так и управление им из кода Python.
Ничто из этого не требует прав суперпользователя или конкретной ОС; сервер привязывает простой TCP-сокет к 127.0.0.1 в примерах, и замена на 0.0.0.0 (с правилом брандмауэра, ограничивающим, кто может к нему получить доступ) — единственное изменение, необходимое для приема подключений от других машин.
Быстрый обзор
Если цель состоит в маршрутизации трафика через множество IP-адресов выхода, а не в изучении того, как внутренне работает прокси, шлюз Nstdata предлагает вам готовый `host:port`, на который можно направить HTTP/SOCKS5-клиенты, вместо того, чтобы поддерживать этот код реле самостоятельно.
Настройка сокета для прослушивания и разбора запросов
Форвард-прокси должен выполнять три действия для каждого подключения: принимать его, прочитать достаточно запроса, чтобы понять, куда он направляется, и соответственно туннелировать или передавать. asyncio.start_server обрабатывает шаг приема и передает каждому подключению пару (reader, writer):
import asyncio async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): first_line = await reader.readline() if not first_line: writer.close() return
header_lines = [first_line.decode(errors="replace").rstrip("\r\n")] while True: line = await reader.readline() if line in (b"\r\n", b"\n", b""): break header_lines.append(line.decode(errors="replace").rstrip("\r\n")) method, target, _ = header_lines[0].split(" ", 2) # метод - "CONNECT" для HTTPS, или "GET"/"POST"/и т.д. для обычного HTTP
Строка запроса является разветвляющей точкой: CONNECT host:port HTTP/1.1 означает, что клиент хочет установить HTTPS туннель и не намерен, чтобы прокси читала его фактический трафик; все остальное является обычным HTTP запросом, который прокси может обработать и передать самостоятельно.
Базовая реализация: ретрансляция обычных HTTP запросов
Для запроса, который не является CONNECT, цель находится либо в самой строке запроса (абсолютная форма, GET http://host:port/path HTTP/1.1), либо в заголовке Host:. Прокси соединяется с этой целью, восстанавливает запрос без заголовков Proxy-*, которые реальный сервер не ожидал бы, и передает байты в обоих направлениях:
async def pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): try: while True: data = await reader.read(65536) if not data: break writer.write(data) await writer.drain() except (ConnectionResetError, BrokenPipeError): pass finally: try: writer.close() except Exception: pass
Внутри handle_client не CONNECT ветка анализирует цель и восстанавливает запрос:
if target.startswith("http://"): rest = target[len("http://"):] host_port, _, path = rest.partition("/") path = "/" + path else: path = target host_port = next( (h.split(":", 1)[1].strip() for h in header_lines[1:] if h.lower().startswith("host:")), None, ) host, _, port = host_port.partition(":") port = int(port or 80) remote_reader, remote_writer = await asyncio.open_connection(host, port) rebuilt = f"{method} {path} HTTP/1.1\r\n" for h in header_lines[1:]: if not h.lower().startswith("proxy-"): rebuilt += h + "\r\n" rebuilt += "\r\n" remote_writer.write(rebuilt.encode()) await remote_writer.drain() await asyncio.gather(pipe(remote_reader, writer), pipe(reader, remote_writer))
Тестировалось с одноразовым локальным HTTP сервером (http.server, привязанным к 127.0.0.1:9000, возвращающим фиксированное тело) с прокси, слушающим на 127.0.0.1:8080:
curl -x http://127.0.0.1:8080 http://127.0.0.1:9000/ -w "\nHTTP_STATUS:%{http_code}\n"
Это вернуло тело hello-from-local-target и HTTP_STATUS:200. Подтверждено с curl -v, что запрос действительно проходил через прокси (> GET http://127.0.0.1:9000/ HTTP/1.1 отправлен на порт 8080, ответ перенаправлен обратно), а не соединение curl с целевым хостом напрямую — реальный риск в любой тестовой среде, где настройка no_proxy может незаметно обойти прокси для определенных хостов, поэтому проверка реального пути имеет больше значения, чем доверять только коду статуса.
Расширенные паттерны: HTTPS туннелирование, аутентификация и конкурентность
Туннелирование HTTPS с помощью CONNECT
Прокси, который обрабатывает только вышеописанный случай, не может передавать HTTPS трафик — клиент собирается начать TLS рукопожатие с целью, и у прокси нет оснований (или возможностей, без закрытого ключа для целевого сайта) его проверять. Решение — это метод CONNECT: прокси открывает сырой TCP соединение с указанным host:port, отвечает 200 Connection Established, и с этого момента просто перегоняет байты в обоих направлениях, не смотря на них:
if method == "CONNECT": host, _, port = target.partition(":") port = int(port or 443) remote_reader, remote_writer = await asyncio.open_connection(host, port) writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n") await writer.drain() await asyncio.gather( pipe(reader, remote_writer), pipe(remote_reader, writer), )
Это именно тот механизм, который описан в справке метода CONNECT MDN: "метод HTTP CONNECT запрашивает, чтобы прокси установил HTTP туннель к целевому серверу, и если он успешен, слепо пересылает данные в обе стороны, пока туннель не закроется." Тестировалось на реальном HTTPS сайте (не макете) через прокси на порту 8080:
curl -x http://127.0.0.1:8080 https://pypi.org/pypi/requests/json
curl -v на той же команде подтвердил полный путь: CONNECT pypi.org:443 отправлен на прокси, 200 Connection Established возвращен, затем SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384, и наконец HTTP/2 200 ответ от самого pypi.org — TLS-рукопожатие произошло от конца до конца между curl и pypi.org через туннель, при этом прокси никогда не видел открытую информацию. Тот же URL также работал из библиотеки requests Python, направленной на прокси через proxies={"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}, возвращая 200 и корректный JSON — подтверждая, что сервер ведет себя как прокси для реальной HTTP-клиентской библиотеки, а не только для curl.
Требуется аутентификация
Открытый релей на публичном интернет быстро злоупотребляют. Добавление основной аутентификации означает проверку заголовка Proxy-Authorization перед выполнением любого пересылки, и возвращение 407 (эквивалент прокси к 401), когда он отсутствует или неправильный:
import base64 AUTH = base64.b64encode(b"devuser:s3cret").decode() # обычно загружается из конфигурации, а не закодировано в коде def check_auth(headers: list[str]) -> bool: for h in headers: if h.lower().startswith("proxy-authorization:"): value = h.split(":", 1)[1].strip() if value.startswith("Basic "): return value[len("Basic "):].strip() == AUTH return False async def send_407(writer: asyncio.StreamWriter): body = b"Требуется аутентификация прокси" writer.write( b"HTTP/1.1 407 Требуется аутентификация прокси\r\n" b'Proxy-Authenticate: Basic realm="proxy"\r\n' b"Content-Length: " + str(len(body)).encode() + b"\r\n" b"Connection: close\r\n\r\n" + body ) await writer.drain() writer.close()
Живые результаты с сервером, поддерживающим аутентификацию, на порту 8081:
| Запрос | Результат |
|---|---|
Нет заголовка Proxy-Authorization | 407 Требуется аутентификация прокси |
Неправильные учетные данные (devuser:wrongpass) | 407 Требуется аутентификация прокси |
| Правильные учетные данные, целевой HTTP | 200, тело переслано корректно |
| Правильные учетные данные, целевой HTTPS через CONNECT | 200 |
Обе ошибки были воспроизведены путем фактической отправки неправильных или отсутствующих учетных данных, а не на основании прочтения кода — та же дисциплина, которую этот проект применяет к каждому заявлению о конфигурации прокси, которое он публикует.
Параллелизм без потока на соединение
Поскольку handle_client является корутиной, событийный цикл asyncio обрабатывает множество соединений параллельно на одном потоке, а не создает поток ОС на каждое клиентское соединение, что является шаблоном, используемым в большинстве учебников по прокси с нуля. Пять одновременных запросов через тот же работающий экземпляр прокси:
time (for i in 1 2 3 4 5; do curl -s -x http://127.0.0.1:8080 http://127.0.0.1:9000/ -o /dev/null -w "req$i:%{http_code} " & done; wait)
Это вывело req1:200 req2:200 req3:200 req4:200 req5:200 и real 0m0.033s. Все пять завершились за 33 миллисекунды, при этом ни один запрос не ждал другой — это поведение, зафиксированное для API потоков asyncio, где обратный вызов start_server выполняется один раз для каждого соединения как независимая корутина, а не блокирующий вызов.
Честные ограничения домашнего прокси-сервера
Все вышеперечисленное — это реальный, работающий обратный прокси — и у него все еще есть границы, о которых стоит знать, прежде чем полагаться на него для чего-либо, кроме локальной разработки или одно-регионального реле.
Самый конкретный случай проявляется в тот момент, когда целевой сайт начинает блокировать IP-адрес прокси: этот сервер имеет только один выходной IP, адрес машины, на которой он работает, так что блокировка здесь блокирует каждого клиента за ним, пока этот IP не изменится. В этот момент ротационный шлюз становится более прямым инструментом для работы, чем сервер, описанный в этом руководстве — линия Residential Lite от Nstdata работает с множеством независимых выходных IP-адресов за одним host:port, используя тот же формат подключения HTTP/SOCKS5, описанный для шлюза, так что запрос, который в противном случае застрял бы на одном заблокированном IP, отправляется с другого, а клиентская сторона кода не должна знать, что произошла ротация. Это выставляется по модели предоплаты, платите по мере использования на странице цен Residential Lite, а не по фиксированной подписке, и предназначено для скриптов и сервисов, которым необходимо распределять трафик по большому, географически разнообразному пулу IP (более 200 стран и регионов по данным объяснения Nstdata о том, как работают HTTP-прокси), а не для команд, которые конкретно хотят владеть и изменять код реле сами — если проверка или ведение журнала трафика на уровне прокси является фактической целью, то самостоятелно размещенный сервер, подобный описанному выше, все еще остается правильным инструментом, а ротационный шлюз является дополнительным шагом вверх, а не заменой.
- Большой распределенный пул выходных IP — множество независимых жилых IP-адресов за шлюзом означает, что один заблокированный или ограниченный по количеству IP не останавливает всю работу так, как это было бы на одном адресе этого самостоятелно размещенного сервера.
- Такая же форма клиентского протокола — шлюз работает с HTTP, HTTPS и SOCKS5 на одной конечной точке, так что код, написанный для прокси в этом руководстве (или для словаря
proxiesбиблиотекиrequests), указывает на шлюз с тем же кодом подключения, просто с другимhost:port. - Глобальные регионы выхода — полезно, когда контент целевого сайта, доступность или лимиты скорости варьируются в зависимости от местоположения запрашивающего, что один самостоятелно размещенный сервер не может воспроизвести, не развернув экземпляр в каждом регионе сам.
Две вещи, которые этот сборка целенаправленно не делает и не следует предполагать, что она делает без дополнительных усилий: она не расшифровывает и не проверяет полезные нагрузки HTTPS (тunnel CONNECT является непрозрачным по дизайну, что правильно для личного реле и избегает управления сертификатами TLS для перехвата), и она не сохраняет журналы, не ограничивает скорость для клиентов и не применяет списки доступа за пределами одной учетной записи Basic-auth, показанной — прокси, открытый за пределами 127.0.0.1, нуждается как минимум в учетных данных для каждого клиента и ограничениях соединения перед тем, как его безопасно оставить работающим без присмотра.
Устранение распространенных ошибок
curl: (7) Не удалось подключиться почти всегда означает, что процесс прокси не слушает на адресе/порту, который был указан curl, или брандмауэр блокирует этот порт — подтвердите с помощью ss -tlnp | grep <port>, что что-то действительно привязано там, перед тем как проверять код прокси.
Запрос, кажется, проходит успешно, даже с неправильным портом прокси, или неправильные учетные данные, похоже, работают — проверьте, установлены ли переменные окружения no_proxy/NO_PROXY в среде оболочки и включают ли целевой хост; несколько изолированных и CI-сред по умолчанию устанавливают это, и curl или requests тихо обойдут настроенный прокси полностью для любого хоста из этого списка. Запустите env -u no_proxy -u NO_PROXY перед командой тестирования, чтобы исключить этот вариант, и подтвердите с помощью curl -v, что строка запроса показывает порт прокси, а не прямое подключение.
502 Bad Gateway от этого прокси означает, что прокси не смог добиться успеха при подключении к целевому хосту — проверьте, что имя хоста целевого объекта разрешается и что порт доступен с машины, запускающей прокси, а не от клиента.
HTTPS работает, но обычный HTTP нет (или наоборот) — это почти всегда связано с разделом строки запроса: подтвердите, что клиент действительно отправляет CONNECT для HTTPS (некоторые библиотеки HTTP-клиентов нуждаются в явной записи прокси https, отдельной от http, прежде чем они это сделают) и в строке запроса в абсолютной форме или в заголовке Host: для обычного HTTP.
Заключение
Работающий прокси-сервер на Python сводится к двум формациям запросов, которые обрабатываются по-разному — обычный HTTP пересылается и перестраивается, HTTPS туннелируется непрозрачно через CONNECT — плюс любая аутентификация и управление параллелизмом, которые действительно нужны развертыванию. Каждый кусочек из этого, включая части, которые большинство быстрых руководств пропускает (реальный HTTPS-туннель, реальный 407, реальные параллельные запросы), был протестирован против живой цели в этом руководстве, а не описан из памяти. Точка, в которой предел одноразового выходного IP становится настоящим узким местом, это тот момент, когда стоит обратиться к управляемому ротационному шлюзу вместо дальнейшей масштабирования этого кода.
Часто задаваемые вопросы
В: Может ли созданный вами Python-прокси-сервер обрабатывать HTTPS-трафик?
Да, но только реализовав метод CONNECT и туннелируя зашифрованные данные, не проверяя их — прокси, который только анализирует строки запроса HTTP (обычная версия первого урока), не может обрабатывать HTTPS, так как клиент никогда не отправляет фактический запрос назначения в открытом виде.
В: Законно ли запускать свой собственный прокси-сервер?
Запуск прокси-сервера сам по себе законен; важно, для чего он используется и авторизован ли трафик, проходящий через него — использование собственного или стороннего прокси для обхода контрольных механизмов доступа, scraping данных в нарушение условий сайта или скрытие незаконной деятельности несет в себе юридические риски вне зависимости от того, чей код осуществляет пересылку.
В: Почему прокси должен поддерживать именно метод CONNECT, вместо того чтобы просто перенаправлять HTTPS-запросы, как HTTP?
Потому что HTTPS-запрос шифруется до того, как он покинет клиент, поэтому прокси, который обрабатывает его так же, как и обычный HTTP, должен видеть открытый текст, который он никогда не получает; CONNECT избегает этого, позволяя прокси слепо передавать байты после открытия туннеля, так что TLS-рукопожатие происходит непосредственно между клиентом и реальной целью.
В: Чем отличается созданный вами прокси-сервер от коммерческого ротационного прокси-сервиса?
Самостоятельно размещенный сервер, как тот, что описан в этом руководстве, имеет столько же выходных IP-адресов, сколько машин, на которых он работает — обычно один — в то время как коммерческий ротационный шлюз находится перед большим управляемым пулом IP-адресов и меняет, какой из них обрабатывает каждый запрос, что имеет значение, когда блокировка по IP или ограничения по скорости (а не сложность кода) становятся фактическим ограничением.
В: Работает ли этот прокси с библиотекой Python requests, или только с curl?
Работает с обоими — сервер поддерживает обычное проксирование HTTP и туннелирование CONNECT на уровне протоколов, так что любой клиент, который реализует это правильно, включая requests через его аргумент proxies, curl -x и браузеры, может использовать его без специальной обработки.





