Представьте себе все содержимое планетарных каналов данных, всю сумму человеческих знаний, вброшенную в хрупкую нейронную сеть Планетарного Разума силами всех имеющихся на планете реакторов. Это последняя попытка человечества спастись от уничтожения пробудившимся инопланетным богом...
Академик Прохор Захаров, «Говорит Планета», Sid Meier’s Alpha Centaury
В этой главе мы поговорим о том, как выполняются действия с потоками данных, такие как их перенаправление, фильтрация и перехват, на разных сетевых уровнях.
Очевидно, что любые данные создаются их производителем и отправляются потребителю с использованием каналов связи. Однако на разных уровнях в канале связи над передаваемыми данными могут производиться разные операции.
На транспортном и сетевом уровне это разбивка на порции и упаковка в PDU, например в сегменты TCP и пакеты IP при передаче данных, и обратный процесс в случае их приема. Как правило, за нас эти действия выполняет сетевой стек, если не используются raw-сокеты и сокеты из семейства AF_PACKET. На данном этапе разработчику могут быть полезны настройки параметров и опции данного процесса, которые были описаны в главе 8 об управлении сокетами.
На канальном и физическом уровне можно перехватывать данные, отправленные другому получателю, если они проходят через общий канал.
После того как данные ушли в канал и были доставлены на маршрутизатор, маршрутизатор определяет, кому их передавать, то есть на какой из портов отправить данные.
Если компьютер подключен напрямую к провайдеру, на вопрос, кому отправлять пакеты, будут отвечать маршрутизаторы провайдера.
Для этого сеть маршрутизаторов строит таблицу маршрутов. Это можно сделать двумя способами: статически, используя конфигурацию, заданную на каждом маршрутизаторе, либо динамически, используя для построения таблицы распределенные протоколы.
На основе характеристик данных, прежде всего адреса назначения, и некой метрики, например времени доставки, маршрутизатор выбирает лучший маршрут. Маршрутизаторы используют целый набор протоколов, которые делятся на работающие внутри провайдера и работающие между провайдерами.
Строго говоря, маршрутизатор использует протоколы внешнего шлюза и внутренние протоколы, которые работают в рамках автономной системы.
Автономная система характеризуется тем, что политика маршрутизации в ней едина и меняется согласованно.
При этом автономная система может принадлежать и нескольким провайдерам, если между ними существует договор. Либо, наоборот, одному крупному провайдеру может принадлежать несколько автономных систем, например, в разных странах.
Автономные системы обсуждаются подробнее в RFC 1930 «Guidelines for creation, selection, and registration of an Autonomous System (AS)».
Для построения маршрутов используются следующие протоколы:
• RIP, OSPF или IS-IS для связи внутри сети провайдера.
• Устаревший EGP, который сейчас заменен протоколом BGP/IDRP для связи между автономными системами.
Эти протоколы, как правило, работают поверх IP, за некоторым исключением: BGP в качестве транспорта использует TCP, а RIP — UDP. Их функционирование управляется сетевым администратором и неподконтрольно программисту. Поэтому в книге мы не будем рассматривать эти протоколы.
К этим же протоколам можно отнести и различные VLAN, которые могут разделять один физический порт, но использовать разные каналы через тегирование различных данных на канальном уровне.
Гораздо более интересна с точки зрения большинства разработчиков обработка данных на высоких уровнях OSI: сеансовом, уровне представления и прикладном.
Сеансовый уровень позволяет реализовать аутентификацию или работу VPN поверх TCP, используя такие протоколы, как PPTP, и поверх UDP через L2F и L2TP.
Уровень представления отражает различные методы изменения представления данных: сжатие, шифрование, сериализацию и т.д. Например, кодирование по ASN.1 в модели OSI применяется к данным на уровне представления. Сериализацию — преобразование данных в формат, удобный для передачи по сети, и различные методы выполнения этого преобразования мы разберем в книге 3.
Шифрование, например, с использованием TLS, усложняет анализ данных, перехваченных из канала на уровнях ниже, а также их подмену.
И наконец, прикладной уровень позволяет работать с данными на уровне протоколов, которые «понимают» их смысл. На этом уровне доступны подробные метаданные: кто, откуда, как и какие данные получил. А также обычно доступны сами данные. На этом уровне работают прокси-серверы, зависимые от конкретных протоколов, и DLP-системы, предотвращающие утечки конфиденциальных данных организации.
В этой главе мы рассмотрим именно проксирование. Подробно поговорим о прокси-серверах, о том, где они используются, чем отличаются от VPN, и реализуем собственный прокси-сервер.
Прокси — это сервер-посредник, который принимает запросы клиента, переадресовывает их целевому серверу и возвращает его ответы.
Обычно прокси знает протокол, используемый клиентом. Например, в WWW широко распространены HTTP- и HTTPS-прокси. Сведения о конкретном протоколе позволяют прокси выполнять широкий круг задач.
Рис. 21.1. Работа без прокси и через прокси
Работа прокси на примере HTTP 1.1 показана на рис. 21.1:
1. Клиент подключается к прокси-серверу.
2. Клиент выполняет GET-запрос к серверу: GET https://www.targetserver.com/path HTTP/1.1. Обратите внимание на особенность: в запросе указан полный URL, а не путь относительно корня.
3. Прокси делает запрос к серверу и возвращает ответ с данными клиенту.
Если не требуется выполнять задачи, специфичные для протокола, прокси служит лишь туннелем для произвольных данных. Например, в HTTP-прокси метод CONNECT используется для открытия туннеля. Параметрами метода являются узел и порт, куда необходимо проксировать данные.
Если прокси с поддержкой этого метода подключится к указанным узлу и порту, он вернет по HTTP код "200 OK" и начнет отправлять данные от клиента серверу и обратно. В этом случае знание HTTP-протокола нужно лишь для того, чтобы подключиться к прокси и сделать вызов.
HTTP- и HTTPS-прокси
Для работы с ресурсами, загружаемыми по HTTPS, прокси используют только HTTP-метод CONNECT, чтобы браузер мог проверить сертификаты целевого сервера.
Соединение между клиентом и прокси может устанавливаться как по HTTP, так и по HTTPS. В любом случае прокси может выполнять разные типы запросов:
• Методом GET — в этом случае можно обращаться только к незащищенным URI по HTTP.
• Методом CONNECT — для прочих ресурсов, например запрашиваемых по HTTPS.
При использовании метода CONNECT и HTTPS до целевого сервера трафик будет шифрованным и прокси не сможет его прочитать. То есть, как написано выше, прокси открывает канал и не знает, какие данные через него передаются, что позволяет в теории проксировать любой протокол.
При использовании HTTPS до прокси никто также не сможет прочитать запрашиваемые URL.
Очевидно, что любой прокси должен понимать, куда передавать данные клиента.
Посмотрим на рис. 21.2. В HTTP-прокси место назначения указывается либо через полный URL в запросе, либо с помощью HTTP-заголовка Host. Аналогично работает и метод CONNECT.
Рис. 21.2. Прохождение запросов через HTTP-прокси
В других типах прокси адрес назначения передается иными способами.
Например, SOCKS-протокол также распространен в интернете, и он, как и прокси, использующие HTTP-метод CONNECT, позволяет работать не только с WWW, но и с другими сервисами.
Не каждый протокол, выполняющий проксирование, работает на прикладном уровне. SOCKS, например, работает на сеансовом. От прикладного уровня он не зависит и потому может передавать любые данные.
В SOCKS адрес задается явно в PDU SOCKS протокола. SOCKS-прокси вообще используют бинарный протокол, не имеющий ничего общего с HTTP. Для SOCKS-прокси знание протокола требуется только для настройки соединения, а также указания того, куда открывать туннель.
SOCKS-протокол
На данный момент существует несколько версий SOCKS-протокола. Версия 4 очень проста. Структура PDU его запроса показана на рис. 21.3.
Рис. 21.3. Структура PDU запроса SOCKS4
Команда бывает двух типов:
• 0x01 — установить TCP-соединение. Подключение к заданному серверу через SOCKS.
• 0x02 — связать TCP-порт. В этом случае клиент говорит SOCKS-серверу о том, что он хочет принять входящее соединение. Этот режим обычно не используется.
Последним элементом структуры является нуль-завершенный идентификатор пользователя.
На основе IP-адресов, номеров портов и пользовательского идентификатора сервер SOCKS 4 определяет, принимать или отбрасывать подключение. Для этого он может отправить запрос на IDENT-сервер, описанный в RFC 1413 «Identification Protocol», который по указанным параметрам выдаст ответ: разрешить или отклонить доступ. Но этот вариант аутентификации давно устарел.
Если запрос удовлетворен, SOCKS-сервер устанавливает соединение с указанным портом узла назначения.
На запрос SOCKS4 прокси отправляет пакет ответа, показанный на рис. 21.4.
Рис. 21.4. Структура PDU ответа в SOCKS4
Коды ответа:
• 0x5a — ответ на запрос.
• 0x5b — запрос отклонен или произошла ошибка.
• 0x5c — недоступен identd, то есть нельзя проверить ID пользователя.
• 0x5d — identd не может подтвердить ID пользователя.
SOCKS 4a — совместимое расширение, которое добавило возможность задавать домен вместо IP-адреса.
SOCKS 5 — протокол с более широкими возможностями, чем SOCKS 4, но без обратной совместимости. Он добавляет схемы аутентификации, поддержку UDP, доменных имен и IPv6. Этот протокол описан в RFC 1928 «SOCKS Protocol Version 5» и нескольких сопряженных: RFC 1929 «Username/Password Authentication for SOCKS V5», RFC 1961 «GSS-API Authentication Method for SOCKS Version 5», в которых представлены методы аутентификации.
Типичный прокси необходим пользователям в следующих целях:
• Для получения доступа к ресурсу, который отказывает в соединении с данного IP либо к которому закрыт доступ на промежуточных маршрутизаторах.
• Для скрытия реального IP-адреса от удаленного абонента, например от веб-сервера. Это может быть полезно для:
• защиты от слежки за перемещением устройства;
• сокрытия устройств с «белым», то есть находящимся не за NAT, IP-адресом от возможных атак;
• устранения ограничений на количество запросов в секунду с одного IP-адреса. Это требуется, например, когда необходимо сохранить какой-либо сайт полностью.
Компании используют прокси в следующих целях:
• Закрытие доступа к определенным ресурсам по их адресам. Доступ в интернет есть только у прокси, и все другие узлы вынуждены подключаться к нему, чтобы получить нужные ресурсы.
• Разрешение доступа к ресурсам, в том числе внешним, только для авторизованных пользователей. Например, без авторизации доступны лишь ресурсы государственных служб, а после авторизации — любые ресурсы, не включенные в «черный список», который также может управляться прокси-сервером.
• Перехват и фильтр содержимого. Фильтровать его можно не только в ответах сервера, но и в запросах клиента: этим занимаются DLP-системы. Обратная сторона этой технологии — использование прокси для взлома. Наоборот, антивирусы работают как прокси для защиты — находят в трафике объекты и проверяют их.
• Кэширование и сжатие содержимого для ускорения ответа и экономии трафика. Например, в организации к веб-ресурсам обращается много пользователей через один веб-прокси, который может кэшировать проходящие данные и реже делать запросы в интернет. Также он может использовать современные расширения HTTP для сжатия трафика, даже если браузеры клиентов их не поддерживают.
• Балансировка нагрузки в случае использования нескольких каналов или маршрутизаторов и контролировать объем трафика от пользователей, причем как осуществляющих доступ к внешним ресурсам изнутри компании, так и внешних пользователей, которые запрашивают ресурсы компании. В первом случае прокси используется достаточно редко, балансировку выполняют на уровне нижележащей сетевой инфраструктуры, например на маршрутизаторах. А для второго случая использование прокси достаточно типично. Только в этом случае применяется другая схема его включения — обратный прокси, который будет рассмотрен далее.
Часто задания прокси требует политика организации. Доступ из сети компании ко всем IP, кроме внутренних, блокируются маршрутизатором, и подключение к интернету осуществляется через прокси.
Прокси, с одной стороны, обладает внутренним IP, с другой — имеет выход во внешнюю сеть. Это позволяет организации контролировать трафик и балансировать нагрузку.
Существуют разные способы задания прокси:
• Явное задание в приложении. Некоторые приложения, такие как браузеры и утилиты для работы с Web, например wget и cURL, позволяют задавать прокси через настройки. Этот тип предполагает, что пользователю известно о наличии прокси, и требуется его задать.
• Прозрачное проксирование. В данном случае о прокси могут знать только сетевые или, реже, системные администраторы.
По той причине, что явное задание прокси специфично для каждого приложения и реализовано по-разному, больший интерес представляет прозрачный режим.
Прозрачное проксирование может выполняться на разных уровнях:
• На уровне сети. В этом случае отдельный маршрутизатор «заворачивает» соединения на прокси, выполняющий необходимую обработку трафика, и пересылает их дальше либо блокирует. Вероятно, это самый частый вариант реализации.
• На уровне операционной системы. Пример — системный сервис . Перенаправление осуществляется через брандмауэр. Данный прокси работает на уровне TCP-соединений и может применяться в том числе для перенаправления DNS-запросов, выполняемых по UDP, через протокол SOCKS 5.
• На уровне приложения. Если приложение не позволяет явно задать прокси, а проксирование на уровне сети или ОС не выполняется, можно перехватывать сетевые вызовы. Так работают приложения или . Так же работает перехватчик вызовов, который будет реализован в главе 25.
Прозрачный L4-прокси
Отдельным интересным случаем можно считать прозрачный L4-прокси, то есть работающий на четвертом — транспортном — уровне OSI.
Например, это может быть тот же Nginx с включенным модулем , позволяющим выполнять проксирование TCP и UDP. Он не знает о вышележащем протоколе и перенаправляет весь поступивший трафик на определенный IP-адрес или группу адресов.
В случае же использования таких модулей, как , Nginx может читать имя хоста и протокол из SSL-запроса, перенаправляя SSL-трафик куда требуется, без его расшифровки.
Перенаправление на такой прокси весьма просто, оно может быть выполнено даже статически на уровне ОС, через записи в файле hosts. Этот вариант проксирования может быть полезен, например, для пересылки больших потоков данных, таких как видео, через указанные серверы.
Другой пример такого L4-прокси — утилита stunnel, которая слушает на определенном порту, шифрует весь приходящий на порт трафик и отправляет его на адрес и порт, указанные в ее конфигурационном файле.
Приложение, которое подключается на порт данной утилиты, считает, что взаимодействует не с прокси, а с целевым сервером, поэтому такое проксирование в какой-то степени тоже можно считать прозрачным.
Существует несколько реализаций прокси для протокола HTTP/HTTPS:
• — некогда мощный прокси для изменения веб-страниц под ОС Windows. Он работал на фильтрах, задаваемых регулярными выражениями, удалял из страниц рекламу и нежелательный контент. Также позволял корректировать запросы пользователя.
• — кросс-платформенное приложение с открытым исходным кодом. Удаляет рекламу и нежелательное содержимое страниц. Поддерживает HTTP, HTTPS и SOCKS.
• — известный кэширующий прокси-сервер с открытым исходным кодом. Поддерживает HTTP, HTTPS, FTP, Gopher и еще ряд протоколов. Не поддерживает SOCKS. Позволяет задавать частоту запросов к ресурсам: например, ограничивать трафик с видеосервисов. Проект кросс-платформенный, но в Windows новые версии работают только через Cygwin.
• — маленький и эффективный прокси-сервер для небольших сетей. Позволяет кэшировать запросы и соединения, фильтровать трафик. Может работать как . Исходный код на C открыт по лицензии GPL-2.
Proxomitron — характерный пример необходимости открытия исходных кодов. Это очень удобная и достаточно простая в управлении эффективная утилита. При этом бесплатная, поставляемая как Freeware.
Он значительно ускорял работу в интернете и делал ее безопаснее. Поэтому вокруг него вскоре сформировалось целое сообщество.
Автором утилиты был Скотт Р. Лемон, который в 2003 году прекратил разработку по неизвестным причинам, а в 2004 году скончался. Исходный код остался в собственности компании, в которой он работал. Она не развивает проект и, видимо, не использует, но код не открывает. А попытки обратной разработки оказались безуспешными.
Существуют отладочные прокси, которые предназначены для поиска ошибок в протоколах. Такие прокси имеют расширенные возможности протоколирования, возможность автоматического обнаружения несоответствий протоколу и расшифровки трафика при взаимодействии по SSL.
Примеры прокси, используемых для отладки HTTP и HTTPS:
• — с открытым исходным кодом. Поддерживает HTTP/1, HTTP/2, HTTPS, WebSocket и другие протоколы.
• — достаточно зрелый и удобный проект, но, к сожалению, не бесплатный. Изначально это был прокси, но сейчас превратился в целый набор инструментов. Продукт кросс-платформенный, работает на Linux, Windows и MacOS. Поддерживает как HTTP версий 1 и 2, так и HTTPS и WebSocket. Позволяет устанавливать точки останова в сессии обмена, захватывать проходящий трафик, а также задавать правила, имитирующие ответ сервера.
• — работает без изменения конфигурации браузера и ручной установки прокси. Дает возможность просматривать различные типы передаваемых данных, находить медленные запросы, изменять HTTP трафик «на лету», имитировать ответы сервера, поддерживает сложную фильтрацию. В основном предназначен для отладки API, выявления проблем с производительностью и безопасностью. Платный инструмент.
• — отладочный прокси, реализованный на Java. Зрелый проект, который был начат в 2002 году. Платный. Имеет такие же возможности, как у большинства подобных инструментов:
• Отображение источников сообщений для всех TCP-соединений, которые проходят через его прокси-порт.
• Возможность внутри HTTP-запроса или ответа просматривать XML, JSON и SOAP в древовидном формате.
• Подсветка для просмотра HTML, CSS и JavaScript.
• Отладка SSL — расшифровывает зашифрованные данные для просмотра/устранения неполадок в передаваемом контенте.
• Регулирование пропускной способности для имитации более низкой скорости интернета.
• Отладка HTTP-соединений с мобильных устройств.
• Возможность замены удаленных файлов локальными для облегчения отладки сайта без необходимости доступа к серверу.
• Вспомогательные средства отладки, такие как повторение запросов на публикацию URL-адресов для проверки изменений сервера, добавление точек останова или редактирование переменных запроса.
Для сетевого разработчика данные инструменты отладки весьма полезны.
Прокси может использоваться не только для получения доступа к ресурсам внешней сети. Другая схема его включения — перед серверами, предоставляющими эти ресурсы. В таком случае прокси называется обратным, или реверс-прокси.
Такой прокси ожидает запросов на определенном порту и внешнем адресе, а затем перенаправляет эти запросы сервису, который не виден клиентам напрямую. Эта техника широко используется для балансировки нагрузки, управления заголовками, передаваемыми серверам, внедрения внешней авторизации для доступа к сервису и т.п.
Программное обеспечение для обратного проксирования бывает разным. Зачастую, в случае WWW, используют веб-сервер, такой как Nginx, который изменяет запросы и переадресует их целевому серверу. Но можно использовать и обычный прокси-сервер, например тот же Squid.
В Squid реализован captive portal, используя который можно отображать веб-страницы, то есть он тоже имеет функциональность простого веб-сервера.
Существуют и специализированные балансировщики. Высоконагруженные сервисы, такие как Github, Stack Overflow, Reddit, используют HAProxy. Он удобен, так как сразу предоставляет возможности, которые полезны для балансировщика: гибкую проверку доступности бэкендов, переписывание URL согласно правилам, поддержку WebSocket, gRPC, ограничение скорости и защиту от DoS-атак, TLS, расшифровку трафика до серверов и т.п.
Сходное решение предоставляет Elastic Load Balancing, или ELB, от Amazon, который автоматически распределяет входящий трафик по нескольким узлам.
На рис. 21.5 показано, как используется обратный прокси. Важно понимать, что с точки зрения разработчика ситуации прямого и обратного проксирования принципиально не различаются. И в том и в другом случае прокси-сервер — это приложение, которое реализует определенный протокол, выполняет работу над единицами данных этого протокола, а затем переадресует их некоторым целевым абонентам или отбрасывает.
Рис. 21.5. Схема включения обратного прокси-сервера
Разница есть лишь с точки зрения пользователя и сетевого администратора: в случае прямого использования прокси его устанавливает пользователь или компания и через него проходят запросы «куда бы то ни было» от пользователей.
На обратный прокси запросы приходят «откуда бы то ни было», а за его настройку отвечает администратор сервиса, предоставляющего ресурс.
Прямой и обратный HTTP-прокси
Небольшое техническое различие между прямым и обратным HTTP-прокси все же есть. Обратный прокси не должен выполнять перенаправление трафика на произвольный URL,а только на заданные по определенным правилам. А значит, он не должен поддерживать HTTP-метод CONNECT, часто используемый в HTTP-прокси для установки туннеля.
Тот же Nginx, нередко применяемый для этой задачи, просто отправляет запрос на выбранный сервис, работающий во внутренней сети, и перенаправляет клиенту ответ с данными. Метод CONNECT он сам по себе не поддерживает: использовать полноценный веб-сервер как обычный прокси странно и небезопасно.
Хотя, при желании, тоже возможно. Например для Nginx существует модуль , который добавляет поддержку этого метода, превращая его в полноценный прямой прокси.
На практике вы можете встретить такое понятие, как «протокол проксирования», или Proxy Protocol. Например, он реализован в AWS Elastic Load Balancing.
Данный протокол обычно используется для взаимодействия обратного прокси и серверов. При этом необходимо настроить на сервере модуль для поддержки этого протокола.
Протокол нужен, когда от прокси требуется сохранить для сервера IP-адрес реального клиента и прочие метаданные, например, чтобы сервер мог корректно определять географический регион клиента.
Протокол дает возможность установки соединения между источником и назначением через прокси-сервер, то есть это проксирование на транспортном уровне. Он полезен, например, для того, чтобы добавить к заголовку запроса заголовок с информацией о соединении, которую возвращают функции getsockname() и getpeername():
• Семейство адресов: AF_INET для IPv4, AF_INET6 для IPv6, AF_UNIX.
• Протокол транспортного уровня.
• Адреса источника и назначения сетевого уровня.
• Порты источника и назначения транспортного уровня, если таковые имеются.
Данный протокол предложен и впервые реализован разработчиками HAProxy и сейчас поддерживается как минимум Nginx и ELB.
На момент написания книги существует две версии протокола:
• Версия 1 — полностью текстовая.
• Версия 2 — с бинарным заголовком.
Весь протокол заключается в следующих несложных правилах:
1. К исходным данным будет добавлен заголовок, указывающий параметры подключения, перечисленные выше.
2. Гарантируется, что заголовок нельзя перепутать с общими протоколами более высокого уровня, такими как HTTP, SSL/TLS, FTP или SMTP, и что форматы версии 1 и версии 2 легко отличимы друг от друга.
3. Оба формата предназначены для размещения в наименьшем TCP-сегменте, равном 536 байт, что гарантирует доставку заголовка сразу и полностью за один вызов send() и recv().
4. Получатель не должен начинать обработку соединения до получения полного заголовка протокола.
5. Получатель может быть терпимым к частичным заголовкам и ожидать прихода заголовка некоторое время или просто сбрасывать соединение. Обычно тайм-аут устанавливается от 3 секунд.
6. Получатель должен всегда ожидать заголовка прокси-протокола, а не пытаться угадать, есть он или нет. Это требуется для исключения проблем с безопасностью.
Из примера ниже станет понятно, что представляет собой заголовок протокола версии 1:
PROXY TCP4 192.168.0.1 192.168.0.11 56324 443
GET / HTTP/1.1
Host: 192.168.0.11
О наличии данного протокола имеет смысл знать, если вы хотите реализовать свой прокси, особенно используемый в качестве обратного: лучше не придумывать свой «лучший протокол» и не реализовывать для веб-серверов его поддержку, а взять готовый и проверенный.
В различных сценариях, например, если необходимо маршрутизировать пакеты на основе их содержимого, а не адресов источника или назначения, требуется обработка пакета приложением, а не просто его перенаправление.
Но для того, чтобы пакет был отправлен приложению, его адрес назначения должен совпадать с адресом узла. Иначе пакет будет отброшен или перенаправлен, если перенаправление включено и возможно.
Если сконфигурировать узел так, что все пакеты будут «локальными», то есть якобы имеющими адрес 0.0.0.0/0, подключиться с узла к любому другому узлу будет невозможно.
Опция сокетов IP_TRANSPARENT отключает проверку IP-адреса источника. Это позволяет приложению обрабатывать пакеты, проходящие через сокет, как будто они были отправлены локальному узлу.
Кроме того, данная опция позволяет получать пакеты пакетов, перенаправленных через iptables TPROXY:
➭ iptables -t mangle -A PREROUTING ! -d ${PROXY_IP} -p tcp -j TPROXY --on-port 8080 --on-ip 127.0.0.1 --tproxy-mark 0x1/0x1
➭ ip rule add fwmark 1 lookup 100
➭ ip route add local 0.0.0.0/0 dev lo table 100
В данном сценарии перенаправляется TCP-трафик, приходящий на порт 8080, кроме трафика, генерируемого самим прокси-сервером.
Приложение может также привязать прослушивающий сокет к любому нелокальному адресу или к 0.0.0.0. И в случае использования TCP функция accept() на этом сокете привяжет нелокальный адрес узла назначения, то есть getsockname() будет возвращать адрес другого узла, того, к которому реально выполнялось подключение.
Для получения адреса в случае UDP необходимо использовать опцию IP_RECVORIGDSTADDR, которая включает отправку содержащего адрес sockaddr_in или sockaddr_in6 вспомогательного сообщения IP_ORIGDSTADDR на уровне SOL_IP.
Пример ее использования:
import socket
# Значение опции.
IP_TRANSPARENT = 0x13
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# Включить опцию.
sock.setsockopt(socket.IPPROTO_IP, IP_TRANSPARENT, 1)
# Под root эта привязка будет работать.
sock.bind(('8.8.8.8', 1234))
sock.listen()
print(f'Listening on {sock.getsockname()}')
while True:
cln_sock, (remote_ip, remote_port) = sock.accept()
local_ip, local_port = cln_sock.getsockname()
print(f'Connection from {remote_ip}:{remote_port} to '
f'{local_ip}:{local_port}')
cln_sock.close()
Например, для HTTP может быть выполнена маршрутизация по URL-адресу внутри HTTP-запроса или по адресу в заголовке Host.
Процесс работы прокси-сервера в этом случае таков:
1. Узел перенаправляет трафик, проходящий через него.
2. Сервер создает сокет, например, на порту 80, и включает опцию IP_TRANSPARENT.
3. Когда через этот узел проходит пакет для другого узла, идущий на порт 80, ядро операционной системы обрабатывает его как пакет, полученный на локальном устройстве, и передает серверу.
4. Сервер анализирует информацию внутри пакета, например URL-адрес внутри HTTP-запроса.
5. На основе этой информации он выбирает нужный удаленный сервер и отправляет на него запрос.
6. Запрос уходит уже на другой сервер, а не на тот, адрес которого был указан изначально в IP-заголовке.
7. Когда приходит ответ от удаленного сервера, сервер отправляет его обратно клиенту через тот же локальный сокет, через который он принял запрос.
Данная опция может быть полезна не только для прокси-серверов, но и, например, для балансировщиков нагрузки.
Внимание! Для работы этой опции должно быть включено перенаправление через SysFS-опцию net.<ipv4|ipv6>.conf.all.forwarding и отключен фильтр обратного пути через net.ipv4.conf.<имя_адаптера>.rp_filter. Использование опции требует привилегий администратора.
IP_FREEBIND — схожая опция, но она позволяет привязать нелокальный адрес только к TCP-сокету. Существует настройка net.<ipv4|ipv6>.ip_nonlocal_bind, разрешающая такую привязку.
Эту опцию удобно использовать, чтобы передавать оригинальный адрес источника бэкенду — тому же Nginx, который находится за обратным прокси.
Если на сокете прокси-сервера установлена эта опция, бэкенду передается реальный адрес подключившегося клиента, а не адрес обратного прокси. Он может быть, например, записан в журнал.
Как рассказал Алексей Кузнецов, изначально опция была внедрена для реализации прозрачного кэширования трафика, и сейчас лучше ее не использовать.
Использование опции IP_TRANSPARENT может привести к проблемам с безопасностью. Например, злоумышленник может отправлять пакеты на локальный порт, который был открыт с опцией IP_TRANSPARENT. Это может привести к обходу механизмов защиты.
Посмотрим, как работает и реализуется прокси, на примере простого HTTP прокси-сервера. Для начала установим прокси в браузере. На рис. 21.6 показана часть окна настройки сети в браузере Firefox:
Хотя мы еще разберем HTTP-протокол подробнее в книге 2, читатель уже должен как минимум представлять его основы.
Рис. 21.6. Часть окна настройки сети в браузере Firefox
Для начала посмотрим, что делает браузер при работе через прокси. В качестве «прокси» используем Netcat, а в браузере зайдем, например, на сайт ya.ru.
В результате получим такой вывод Netcat:
➭ nc -l -p 8080
GET http://ya.ru/ HTTP/1.1
Host: ya.ru
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:93.0) Gecko/20100101 Firefox/93.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
DNT: 1
Connection: keep-alive
Cookie: is_gdpr=0; is_gdpr_b=COaIGBC4TygC; yp=1643621751.ygu.1; mda=0; yandexuid=4936181221536216044; yandex_gid=16; ndsp=eyJk...ODI0fQ%3D%3D
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0
Из него видно, что при использовании прокси, как и предполагалось, браузер отправляет в GET-запросе полное имя узла, а также обязательный заголовок Host, соответствующий имени целевого узла. Используя эти данные, HTTP-прокси и определяет целевой узел. Реализация такого прокси рассмотрена далее, а его полный код вы можете увидеть в репозитории книги.
Рассмотрим основные участки кода, чтобы понять, как работает проксирование.
Метод ProxyServer::connect_to_target_server() выполняет подключение к целевому узлу. Изучим сначала его IPv4-часть:
// Метод для подключения к целевому узлу.
socket_wrapper::Socket
ProxyServer::connect_to_target_server(const std::string &host_name,
unsigned short port,
socket_wrapper::Socket &sock)
{
// Получить адреса для подключения. Функция из обертки.
// Вызывает getaddrinfo().
const auto servinfo = socket_wrapper::get_client_info(host_name, port);
// DNS может выдать несколько адресов.
// Не все они потенциально рабочие.
// Соединиться, используя первый адрес, который принимает соединения.
for (auto const *s = servinfo; s != nullptr; s = s->ai_next)
{
assert(s->ai_family == s->ai_addr->sa_family);
if (AF_INET == s->ai_family)
{
sockaddr_in *const sin =
reinterpret_cast<sockaddr_in *const>(s->ai_addr);
in_addr addr;
addr.s_addr =
*reinterpret_cast<const in_addr_t *>(&sin->sin_addr);
sin->sin_family = AF_INET;
sin->sin_port = htons(port);
std::array<char, INET_ADDRSTRLEN> ip;
std::cout
<< "Trying IP Address: "
<< inet_ntop(AF_INET, &addr, ip.data(), ip.size())
<< std::endl;
socket_wrapper::Socket sock = {AF_INET, SOCK_STREAM, IPPROTO_TCP};
// Попытка соединения.
if (try_to_connect(sock, reinterpret_cast<const sockaddr *>(sin),
sizeof(sockaddr_in)))
{
// Если попытка удалась, этот адрес и сокет будут
// использоваться дальше.
return sock;
}
}
Часто сервер предоставляет несколько адресов, и при неудачной попытке подключения к одному необходимо попробовать следующий, что этот метод и делает.
Если вы читали главы про сеть в ОС Windows, то наверняка заметили, что в ней уже реализована функция WSAConnectByList(), которая делает подобное.
Аналогично выполняется обработка адресов IPv6:
// То же самое для IPv6.
else if (AF_INET6 == s->ai_family)
{
sockaddr_in6 *const sin =
reinterpret_cast<sockaddr_in6 *const>(s->ai_addr);
sin->sin6_family = AF_INET6;
sin->sin6_port = htons(port);
// Все то же самое, но с IPv6-адресом.
std::array<char, INET6_ADDRSTRLEN> ip6;
std::cout
<< "Trying IPv6 Address: "
<< inet_ntop(AF_INET6, &(sin->sin6_addr), ip6.data(),
ip6.size())
<< std::endl;
socket_wrapper::Socket sock = {AF_INET6, SOCK_STREAM,
IPPROTO_TCP};
// Попытка соединения.
if (try_to_connect(sock,
reinterpret_cast<const sockaddr *>(sin),
sizeof(sockaddr_in6)))
{
// Соединение удачно, вернуть сокет.
return sock;
}
}
} // Окончание цикла for
throw std::system_error(sock_wrap_.get_last_error_code(),
std::system_category(),
"Connection error");
}
Метод, показанный ниже, в случае ошибки формирует HTTP-ответ пользователю в виде HTML-страницы, которая содержит текст, уведомляющий о том, что произошла ошибка:
void ProxyServer::client_error(
const socket_wrapper::Socket &sock, const std::string &cause,
int err_num, const std::string &short_message,
const std::string &long_message) const
{
std::string err_headers = "HTTP/1.0 " + std::to_string(err_num) + " " +
short_message + "\r\n";
// Отправить строку HTTP-ответа.
if (-1 == send(sock, &err_headers.at(0), err_headers.size(), 0))
{
throw std::system_error(sock_wrap_.get_last_error_code(),
std::system_category(), "send");
}
// Заголовок, говорящий о том, что будет отправлена HTML-страница.
err_headers = "Content-type: text/html\r\n";
if (-1 == send(sock, &err_headers.at(0), err_headers.size(), 0))
{
throw std::system_error(sock_wrap_.get_last_error_code(),
std::system_category(), "send");
}
std::stringstream err_body_s;
err_body_s
<< "<html><title>Proxy Error</title>" << "<body bgcolor=0xffffff>\r\n"
<< err_num << ": " << short_message << "\r\n"
<< "<p>" << long_message << ": " << cause << "\r\n"
<< "<hr><em>Example Proxy Server</em>\r\n" << "</body></html>\r\n";
auto err_body = err_body_s.str();
err_headers = "Content-length: " + std::to_string(err_body.size()) +
"\r\n\r\n";
// Отправить HTTP-заголовки.
if (-1 == send(sock, &err_headers.at(0), err_headers.size(), 0))
{
throw std::system_error(sock_wrap_.get_last_error_code(),
std::system_category(), "send");
}
// Отправить клиенту HTTP-тело, то есть сгенерированную HTML-страницу
// с ошибкой. Все это можно было сделать и за один вызов send().
if (-1 == send(sock, &err_body.at(0), err_body.size(), 0))
{
throw std::system_error(sock_wrap_.get_last_error_code(),
std::system_category(), "send");
}
}
Метод parse_headers() разбирает заголовки. На место пропущенных заголовков данный прокси добавляет свои, зашитые в коде. Например, заголовок User-Agent будет подменен, и сервер не узнает, каким браузером вы пользуетесь, что может повлечь за собой как позитивные, так и негативные последствия:
// Здесь разбираются заголовки HTTP.
// Часть из них пропускается.
// В методе proxify() вместо них будут добавлены заголовки,
// которые зашиты в коде.
std::tuple<std::string, std::string> ProxyServer::parse_request_headers(socket_wrapper::Socket &s) const
{
std::string line;
std::stringstream result;
std::string host_name;
static constexpr char host_header_name[] = "Host: ";
do
{
line = read_line(s);
if (("\r\n" == line) || ("\n" == line))
continue;
// Эти заголовки будут пропущены.
if (line.find("User-Agent:") != std::string::npos)
continue;
if (line.find("Accept:") != std::string::npos)
continue;
if (line.find("Accept-Encoding:") != std::string::npos)
continue;
if (line.find("Connection:") != std::string::npos)
continue;
if (line.find("Proxy-Connection:") != std::string::npos)
continue;
// Тут будет получено имя узла из заголовка Host.
// К этому узлу в дальнейшем и будет осуществляться подключение.
if (const auto host_pos = line.find(host_header_name);
host_pos != std::string::npos)
{
// Заголовок найден, извлечь доменное имя.
host_name = line.substr(host_pos +
std::size(host_header_name) - 1);
continue;
}
// Остальные заголовки.
result << line;
} while ((line != "\r\n") && (line != "\n"));
return make_pair(result.str(), host_name);
}
Больше всего нас интересует метод proxify(), который выполняет основную работу:
• Прием запроса от клиента.
• Разбор запроса и возможное изменение его заголовков и тела.
• Отправка целевому серверу измененного запроса.
• Прием ответа сервера.
• Разбор ответа и возможное изменение его заголовков и тела, что здесь не реализуется.
• Отправка измененного ответа клиенту по запросу.
Для разбора URL внутри метода proxify() используется метод parse_uri(), выделяющий различные компоненты из строки. Он использует регулярное выражение. Рассматривать сам метод мы не будем, только приведем его прототип:
// Метод выделяет имя хоста, порт и путь к ресурсу.
ProxyServer::uri_data ProxyServer::parse_uri(const std::string &uri) const;
Кроме вызова регулярного выражения, выделения групп и формирования строкового потока, в нем почти ничего нет.
Сначала proxify(), выполнив чтение строки из сокета и ее разбивку на компоненты, проверяет соответствие метода HTTP:
void ProxyServer::proxify(socket_wrapper::Socket client_socket)
{
// В этом методе выполняется проксирование.
std::cout << "Waiting for the client request..." << std::endl;
try
{
std::string method;
std::string uri;
std::string version;
auto line = read_line(client_socket);
// Получить метод запроса, URL и версию HTTP.
std::stringstream ss(rtrim(line));
ss >> method >> uri >> version;
std::cout
<< "Client request: \"" << line << "\" parsed.\n"
<< "Method = " << method << "\n"
<< "URI = " << uri << "\n"
<< "Version = " << version
<< std::endl;
// Сейчас поддерживается только метод GET.
if (method != "GET")
{
std::cerr
<< "Unknown method: \"" << method << "\""
<< std::endl;
client_error(client_socket, method, 501, "Not implemented",
"This proxy does not implement this method");
throw std::system_error(sock_wrap_.get_last_error_code(),
std::system_category(), "send");
}
Для упрощения примера мы реализовали поддержку только GET-запросов. Следующий этап — разбор URL и заголовков:
// Получим имя хоста, путь к ресурсу на сервере и порт
// из строки запроса.
auto [host_name, path, port] = parse_uri(uri);
std::cout
<< "Host name from the request = " << host_name << "\n"
<< "Path = " << path << "\n"
<< "Port from the request = " << port
<< std::endl;
// Разбираем HTTP-заголовки клиента.
auto [new_headers, host_name_from_header] =
parse_request_headers(client_socket);
if (host_name_from_header.size())
{
std::tie(host_name, std::ignore, port) =
parse_uri(host_name_from_header);
std::cout
<< "Host from the header = " << host_name << "\n"
<< "Port from the header = " << port
<< std::endl;
}
Теперь нужно выяснить, куда отправлять запрос, скорректировать заголовки и построить новый запрос:
// Создаем новый запрос, который будет отправлен на сервер.
std::stringstream new_request_s;
// Запрос.
new_request_s << "GET " << path << " HTTP/1.1\r\n";
// HTTP-заголовки.
new_request_s
<< "Host: " << host_name << "\r\n"
<< user_agent_hdr
<< accept_hdr
<< accept_encoding_hdr
<< connection_hdr
<< proxy_conn_hdr
<< new_headers
<< "\r\n";
const std::string& new_request = new_request_s.str();
std::cout
<< "\n"
<< "============\n"
<< "New request:\n"
<< "------------\n"
<< new_request
<< "============\n\n"
<< "Connecting to host: \"" << host_name
<< ":" << port
<< "\"..."
<< std::endl;
Отправим запрос на целевой узел:
// Создаем новое подключение к целевому серверу.
auto &&proxy_to_server_socket = connect_to_target_server(host_name,
port);
std::cout
<< "Connected.\n\n"
<< "Writing HTTP request to the target server..."
<< std::endl;
// Передаем запрос пользователя с заголовками,
// модифицированными прокси-сервером.
if (send(proxy_to_server_socket, &new_request.at(0),
new_request.size(), 0) < 0)
{
client_error(client_socket, method, 503, "Internal error",
sock_wrap_.get_last_error_string());
throw std::system_error(sock_wrap_.get_last_error_code(),
std::system_category(), "send");
}
std::cout
<< "Request was written.\n\n"
<< "Reading response from the target server..."
<< std::endl;
Остается только прочитать ответ узла и перенаправить его клиенту, использующему прокси:
std::string res0ponse;
do
{
// HTTP 1.x — текстовый протокол.
// Можно читать ответ построчно,
// хотя это неэффективно.
line = read_line(proxy_to_server_socket);
response += line;
} while (line.size());
std::cout
<< "Response was read.\n"
<< "=======================\n"
<< "Target server response:\n"
<< "-----------------------\n"
<< response
<< "\n=======================\n"
<< std::endl;
std::cout
<< "Forwarding response to the client..."
<< std::endl;
// Передать ответ целевого сервера клиенту.
if (send(client_socket, &response.at(0), response.size(), 0) < 0)
{
throw std::system_error(sock_wrap_.get_last_error_code(),
std::system_category(), "send");
}
}
catch(const std::exception &e)
{
std::cerr << e.what() << std::endl;
}
catch(...)
{
std::cerr << "Unknown exception in the client thread!" << std::endl;
}
}
Попробуем запустить сервер и открыть http://neverssl.com в браузере. Ниже приведен сокращенный ответ. Видим, как отправляется запрос:
➭ build/bin/b01-ch21-simple-http-proxy 8080
Listening on port 8080...
Accepted connection
Creating client thread...
Waiting for the client request...
Client request: "GET http://neverssl.com/ HTTP/1.1" parsed.
Method = GET
URI = http://neverssl.com/
Version = HTTP/1.1
Host name from the request = neverssl.com
Path = /
Port from the request = 80
Host from the header = neverssl.com
Port from the header = 80
============
New request:
------------
GET / HTTP/1.1
Host: neverssl.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:93.0) Gecko/20100101 Firefox/93.0
Accept: text/html,application/xhtml+xml,application/xml;
Accept-Encoding: identity
Connection: close
Proxy-Connection: close
Accept-Language: ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3
DNT: 1
Cookie: PHPSESSID=f9s83rf9p85huvp6rtk6fqm3l5; XYZSRV=wtc2-c
Upgrade-Insecure-Requests: 1
============
Connecting to host: "neverssl.com:80"...
Trying IP Address: 52.222.174.19
Connected.
Writing HTTP request to the target server...
Request was written.
Reading response from the target server...
Response was read.
После чего браузер получает основную HTML-страницу и делает несколько запросов для получения ресурсов:
=======================
Target server response:
-----------------------
HTTP/1.1 200 OK
Date: Tue, 09 Jul 2024 20:58:59 GMT
Server: Apache/2.4.58 ()
Upgrade: h2,h2c
Connection: Upgrade, close
Last-Modified: Wed, 29 Jun 2022 00:23:33 GMT
ETag: "f79-5e28b29d38e93"
Accept-Ranges: bytes
Content-Length: 3961
Vary: Accept-Encoding
Content-Type: text/html
<html>
<head>
<title>NeverSSL — Connecting ... </title>
...
</head>
<body>
...
<h2>What?</h2>
<p>This website is for when you try to open Facebook, Google, Amazon, etc
on a wifi network, and nothing happens. Type "http://neverssl.com"
...
<a href="https://twitter.com/neverssl">Follow @neverssl</a>
</noscript>
</div>
</div>
</body>
</html>
=======================
Forwarding response to the client...
Accepted connection
Creating client thread...
Waiting for the client request...
Client request: "GET http://oldwholeyoungrain.neverssl.com/online HTTP/1.1" parsed.
...
Иногда браузер пытается использовать метод CONNECT, который нашим прокси сейчас не поддерживается:
Listening on port 8080...
Accepted connection
Creating client thread...
Waiting for the client request...
Client request: "CONNECT alive.github.com:443 HTTP/1.1" parsed.
Method = CONNECT
URI = alive.github.com:443
Version = HTTP/1.1
Unknown method: "CONNECT"
Видно, что прокси может быть помехой в канале и искажать данные, но может быть и полезным инструментом диагностики различных проблем, в том числе связанных с нарушениями протокола. В любом случае при диагностике канала учитывайте возможное наличие прокси-сервера.
На Python базовый прокси выглядит так:
import sys
from proxy import entry_point
if __name__ == '__main__':
sys.exit(entry_point())
Понятно, что здесь прокси-сервер реализуется библиотекой. И в данном случае это — библиотека проксирования с широкими возможностями, которая поддерживает DoH, TLS, плагины, обратное проксирование, SSH-туннелирование и многое другое.
Мы выбрали именно эту библиотеку, потому что она достаточно популярна. И хотя ее код далеко не идеален, ее будет интересно разобрать как пример долго живущего, реально используемого открытого проекта. Будем рассматривать версию 2.4.3.
Запустим прокси:
➭ src/book01/ch20/python/proxy-py-test.py --port 12345
2022-08-03 18:06:25,689 — pid:1305964 [I] plugins.load:85 — Loaded plugin proxy.http.proxy.HttpProxyPlugin
2022-08-03 18:06:25,691 — pid:1305964 [I] tcp.listen:80 — Listening on 127.0.0.1:12345
2022-08-03 18:06:25,715 — pid:1305964 [I] pool.setup:105 — Started 8 acceptors in threadless (local) mode
2022-08-03 18:07:32,569 — pid:1305968 [I] server.access_log:384 — 127.0.0.1:47900 — GET ya.ru:80/ — 301 Moved permanently — 913 bytes — 1700.27ms
И проверим работоспособность через Netcat:
➭ nc localhost 12345
GET http://ya.ru/ HTTP/1.1^M
Host: ya.ru^M
^M
HTTP/1.1 301 Moved permanently
Cache-Control: max-age=1209600,private
Location: https://ya.ru/
NEL: {"report_to": "network-errors", "max_age": 86400, "success_fraction": 0.001, "failure_fraction": 0.1}
P3P: policyref="/w3c/p3p.xml", CP="NON DSP ADM DEV PSD IVDo OUR IND STP PHY PRE NAV UNI"
Portal: Home
Report-To: { "group": "network-errors", "max_age": 86400, "endpoints": [ { "url": "https://dr.yandex.net/nel"}]}
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
X-Yandex-Req-Id: 4688-vla-l7-balancer-8080-BAL-5190
set-cookie: is_gdpr=0; Path=/; Domain=.ya.ru; Expires=Fri, 02 Aug 2024 15:02:12 GMT
set-cookie: is_gdpr_b=LFoSIRANraDaAe==; Path=/; Domain=.ya.ru; Expires=Fri, 02 Aug 2024 15:02:12 GMT
set-cookie: _yasc=kQ..vmw==; domain=.ya.ru; path=/; expires=Fri, 02-Sep-2022 15:02:12 GMT; secure
0
^M
Внимание! Данный прокси не работает с переводами строки в запросе. Строки обязательно должны оканчиваться символами возврата каретки и перевода строки — \r\n. Чтобы вставить символы, в консоли необходимо нажать Ctrl+V+Enter, что будет отображаться как ^M, а затем еще раз нажать Enter.
Функционирование прокси понятно из предыдущего кода на C++, потому сразу перейдем к рассмотрению кода библиотеки.
После установки через pip прокси можно запустить напрямую, без написания Python-кода:
➭ proxy
2022-08-21 03:11:02,701 — pid:828112 [I] plugins.load:85 — Loaded plugin proxy.http.proxy.HttpProxyPlugin
2022-08-21 03:11:02,701 — pid:828112 [I] tcp.listen:80 — Listening on 127.0.0.1:8899
2022-08-21 03:11:02,713 — pid:828112 [I] pool.setup:105 — Started 8 acceptors in threadless (local) mode
2022-08-21 03:11:04,861 — pid:828112 [I] proxy._handle_exit_signal:325 — Received signal 2
2022-08-21 03:11:04,861 — pid:828112 [I] pool.shutdown:125 — Shutting
down 8 acceptors
Вызывающий его файл будет помещен в соответствующий каталог bin: /usr/bin или .local/bin/proxy.
Сначала посмотрим на структуру кода. В корне библиотеки содержится несколько каталогов и файлов:
• common — различные общие файлы. Интерес представляют следующие:
• constants.py — сюда вынесены значения по умолчанию.
• flag.py — обертка над парсером агрументов, которая добавляет базовые параметры командной строки и устанавливает их значения по умолчанию.
• pki.py — функции для работы с ключами PKI. Не используются при работе прокси. Это отдельная утилита, которая служит для упрощения, например, генерации ключей администратором.
• plugins.py — класс, реализующий загрузку плагинов.
• utils.py — разные функции, в том числе отвечающие за соединение и его обертывание в шифрующий контекст TLS. Наличие такого файла характерно для многих проектов. Но это антипаттерн. И вероятно, автор впоследствии разобьет его на более мелкие файлы, сгруппировав функции по их назначению.
• core — ядро библиотеки:
• acceptor — реализация класса Acceptor, принимающего соединения, и пула акцепторов.
• base — базовые классы для обработчика HTTP и SOCKS-протокола — BaseTcpServerHandler, туннеля — BaseTcpTunnelHandler, обратного прокси и пула серверов — TcpUpstreamConnectionHandler.
• connection — соединения. Базовый класс TcpConnection, содержащий методы отправки и приема данных и выполняющий буферизацию. Его наследники: TcpClientConnection для обертывания клиентских сокетов и TcpServerConnection — подключение к целевым, или «восходящим», серверам. А также класс UpstreamConnectionPool, содержащий пул «восходящих» соединений.
• event — подсистема обмена событиями: диспетчер, очередь, менеджер и подписчик.
• listener — прослушиватели, ожидающие подключения от клиента: BaseListener — базовый класс, TcpSocketListener — по TCP, UnixSocketListener — через сокеты Unix-domain. ListenerPool — пул, управляющий созданием и временем жизни листенеров.
• ssh — поддержка соединений по SSH-протоколу.
• tls — поддержка TLS: слоя шифрования и аутентификации, протокола HTTPS.
• work — различные исполнители: безпотоковый, многопоточный и т.п., классы Task и Work, содержащие выполняемую задачу. С точки зрения сетевого программирования нам они не очень интересны.
• dashboard — плагин, реализующий панель управления сервером, его веб-интерфейс.
• http — поддержка HTTP и HTTPS:
• exception — ошибки, отправляемые сервером в ответ HTTP.
• inspector — поддержка Chrome DevTools Protocol. Дает возможность анализировать и менять проходящий через прокси HTTP трафик, пользуясь инструментарием браузера Google Chrome.
• parser — парсер запросов и ответов HTTP.
• proxy — класс HttpProxyBasePlugin. Базовый для плагинов HTTP-прокси. И класс HttpProxyPlugin, реализующий HTTP-прокси, а также управление его плагинами.
• server — локальный веб-сервер, используемый, когда прокси работает как обычный сервер, и обратный прокси — класс ReverseProxy.
• websocket — плагин и классы для поддержки WebSocket-протокола.
• descriptors.py — класс DescriptorsHandlerMixin, используемый для работы с циклами обработки событий.
• handler.py — класс HttpProtocolHandler: обработчик HTTP, HTTP2, HTTPS и WebSocket. Это класс-обработчик по умолчанию — DEFAULT_WORK_KLASS.
• url.py — класс Url, поддерживающий разбор URL и его десериализацию.
• plugin — различные плагины:
• имитация ответов сервера через REST API;
• фильтрация по URL и IP;
• поддержка сокращенных имен популярных сайтов;
• подмена DNS на пользовательский;
• изменение тела HTTP-запросов;
• поддержка на уровне прокси дискового кэширования получаемых объектов
и многие другие.
• socks — поддержка SOCKS-протокола: клиент, обработчик, парсер.
• testing — класс для организации тестирования.
• proxy.py — файл, содержащий объединяющий класс Proxy.
• __init__.py — при импортировании главного модуля здесь экспортируются часто используемые модули, чтобы было возможно осуществлять импорт сразу из proxy.
• __main__.py — точка входа для запуска прокси через python -m proxy.
Теперь взглянем на структуру классов, показанную на рис. 21.7. Мы убрали поддержку TLS, SSH, WebSocket и некоторые избыточные связи.
Рис. 21.7. Структура классов Proxy.Py
Видим, что данная библиотека достаточно сложна. Из всего обилия ее сущностей интерес для нас представляют сетевые вызовы, которые мы в основном и будем изучать в данной главе. Но сначала разберемся в общем процессе функционирования библиотеки.
На рис. 21.8 показано, как прокси-сервер принимает входящие соединения. Объект ListenerPool прослушивает настроенный порт сервера. Операции accept() на сокетах, готовых для приема входящих клиентских соединений, выполняются объектом класса AcceptorPool — пулом акцепторов.
Рис. 21.8. Работа класса AcceptorPool
По умолчанию proxy.py попытается использовать все доступные ему ядра ЦП для приема новых клиентских соединений.
Если режим без использования потоков включен, настраивается экземпляр ThreadlessPool, который запускает процессы Threadless для обработки входящих клиентских соединений.
Принятое клиентское соединение делегирует процессу через класс обработчика соответствующий процесс Acceptor.
В объекте класса прокси-сервера содержится пул соединений к целевому серверу. Из пула он получает соединение для каждого запроса клиента, отправляет запрос, используя это соединение, и возвращает клиенту результат по завершении запроса.
Классом-обработчиком по умолчанию является HttpProtocolHandler, который обрабатывает запросы клиентов по HTTP.
В proxy.py все является подключаемым модулем или плагином:
• Конкретные реализации HTTP-прокси и HTTP-сервера представляют собой подключаемые модули HttpProtocolHandler.
• Прокси-сервер реализуется плагином HttpProxyPlugin.
• Все плагины прокси-сервера, то есть плагины HttpProxyPlugin, реализуют класс-интерфейс HttpProxyBasePlugin.
У плагинов тоже могут быть свои плагины. Например, встроенный веб-сервер HttpWebServerPlugin является плагином непосредственно HttpProtocolHandler и реализует интерфейс HttpProtocolHandlerPlugin.
Сложность работы библиотеки обусловлена тем, что она стремится обеспечить высокую производительность. Для этого используются методы и концепции, которые мы опишем в следующих книгах.
Перейдем непосредственно к изучению кода.
Внимание! Код прокси не обязательно соответствует лучшим практикам для Python или стилю PEP8. Это код реального приложения, автор которого может нарушать соглашения. За это авторы книги ответственности не несут.
Сначала прокси-сервер читает переданные аргументы командной строки в функции main(), вызываемой из entry_point():
def main(**opts: Any) -> None:
with Proxy(sys.argv[1:], **opts):
# Ожидает секунду и обрабатывает исключение KeyboardInterrupt.
# Необходимо для того, чтобы после выхода из with не был вызван
# shutdown().
sleep_loop()
Эти аргументы передаются создаваемому в функции main() экземпляру класса proxy.Proxy. Это основной класс библиотеки:
class Proxy:
"""Proxy - контекстный менеджер, управляющий ядром библиотеки.
По умолчанию запускает proxy.core.pool.AcceptorPool` с классом-воркером
proxy.http.handler.HttpProtocolHandler.
"""
def __init__(self, input_args: Optional[List[str]] = None, **opts: Any):
# Обработка флагов. Наиболее важным здесь является то, что
# из строковой опции получается ссылка на реальный класс в параметре
# work_klass, для чего импортируется плагин, который поддерживает
# соответствующий протокол.
self.flags = FlagParser.initialize(input_args, **opts)
...
# Будет вызван из блока with.
def __enter__(self) -> 'Proxy':
self.setup()
return self
# Будет вызван при выходе из блока with.
def __exit__(self, *args: Any) -> None:
self.shutdown()
В нем создаются прослушивающие сокеты, процессы, исполняющие код обработчиков протокола, и акцепторы:
def setup(self) -> None:
# Записать PID-файл.
self._write_pid_file()
# Установить пул прослушивателей.
self.listeners = ListenerPool(flags=self.flags)
self.listeners.setup()
...
# Создать пул исполняющих процессов.
if self.remote_executors_enabled:
self.executors = ThreadlessPool(
flags=self.flags,
event_queue=event_queue,
executor_klass=RemoteFdExecutor,
)
self.executors.setup()
# Создать пул акцепторов.
self.acceptors = AcceptorPool(
# Через флаги передается класс обработчика.
# proxy.http.HttpProtocolHandler по умолчанию.
flags=self.flags,
# Пулу акцепторов передаются ранее созданные прослушивающие
# сокеты.
listeners=self.listeners,
# Очереди заданий, идентификаторы процессов и блокировки
# исполняющих процессов.
executor_queues=self.executors.work_queues if self.executors
else [],
executor_pids=self.executors.work_pids if self.executors else [],
executor_locks=self.executors.work_locks if self.executors
else [],
event_queue=event_queue,
)
# Запустить акцепторы и передать им дескрипторы прослушивающих
# сокетов.
self.acceptors.setup()
...
Для TCP экземпляр ListenerPool создает объекты класса TcpSocketListener с нужным портом. Собственно, эти объекты создают прослушивающий сокет, устанавливают опции и запускают listen():
class TcpSocketListener(BaseListener):
"""Tcp listener."""
def __init__(self, *args: Any, port: Optional[int] = None, **kwargs: Any):
# Если порт был передан, он будет использоваться.
# В ином случае будет использован порт из опции.
self.port = port
# Сюда сохраняется выделенный автоматически порт.
self._port: Optional[int] = None
super().__init__(*args, **kwargs)
На сокете функция listen() вызывается в методе класса Proxy с идентичным названием:
# Этот метод будет вызван из базового класса, если он появится
# в блоке with или явно, через его метод setup().
# Базовый класс также закроет сокет при выходе из with.
def listen(self) -> socket.socket:
sock = socket.socket(
socket.AF_INET6 if self.flags.hostname.version == 6
else socket.AF_INET,
socket.SOCK_STREAM,
)
# Взвести уже известный нам флаг сокета, который еще будет рассмотрен
# подробно.
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# Отключить алгоритм Нейгла.
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
# s.setsockopt(socket.SOL_TCP, socket.TCP_FASTOPEN, 5)
# Получить реальный порт.
port = self.port if self.port is not None else self.flags.port
# Привязать адрес.
sock.bind((str(self.flags.hostname), port))
# Принимать соединения.
sock.listen(self.flags.backlog)
# Внутри данный прокси работает в неблокирующем режиме.
sock.setblocking(False)
# Порт, на котором реально идет прослушивание.
self._port = sock.getsockname()[1]
# Некорректное форматирование строки журналирования.
logger.info(
'Listening on %s:%s' % (self.flags.hostname, self._port),
)
return sock
Данный прокси может прослушивать на разных портах одновременно, поэтому прослушивателей может быть создано несколько.
После создания прослушивателей создаются воркеры. Например, ThreadlessPool создаст пул из процессов — объектов multiprocessing.Process и воркеров, или исполнителей, — переданного ему класса, взаимодействие с которыми будет производиться через канал multiprocessing.Pipe.
Воркер может быть экземпляром одного из классов, показанных на рис. 21.9, например RemoteFdExecutor, который принимает задачи через multiprocessing.Connection — концы одного канала, созданного Pipe. Внутри данных воркеров работает асинхронный событийный цикл.
Рис. 21.9. Исполнители в Proxy.py
Только после этого объект класса AcceptorPool создает акцепторы, передает им дескрипторы прослушивающих сокетов и запускает исполнение:
class AcceptorPool:
"""AcceptorPool создает классы proxy.core.acceptor.acceptor.Acceptor,
стараясь использовать все доступные ядра CPU.
Передает дескрипторы, из которых приходят задачи, через пайп.
Класс-обработчик берется из `flags.work_klass`.
"""
def __init__(
self,
flags: argparse.Namespace,
listeners: ListenerPool,
executor_queues: List[connection.Connection],
executor_pids: List[int],
executor_locks: List['multiprocessing.synchronize.Lock'],
event_queue: Optional['EventQueue'] = None,
) -> None:
# Через флаги передается класс обработчика.
self.flags = flags
# Файловые дескрипторы для получения новых задач.
# Пул сокетов, ожидающих соединения от клиента.
self.listeners: ListenerPool = listeners
# Доступные исполнители.
self.executor_queues: List[connection.Connection] = executor_queues
self.executor_pids: List[int] = executor_pids
...
# Список процессов акцепторов.
self.acceptors: List[Acceptor] = []
# Очереди файловых дескрипторов, служащие для разделения дескрипторов
# с процессами акцепторов.
self.fd_queues: List[connection.Connection] = []
...
def setup(self) -> None:
"""Создать и запустить акцепторы."""
self._start()
...
# Отправить дескрипторы процессам акцепторов.
for index in range(self.flags.num_acceptors):
self.fd_queues[index].send(len(self.listeners.pool))
for listener in self.listeners.pool:
fd = listener.fileno()
# Здесь используется функция из модуля multiprocess.
send_handle(
self.fd_queues[index],
fd,
self.acceptors[index].pid,
)
self.fd_queues[index].close()
...
Непосредственно запуск производится в методе _start(), который вызывает метод start() акцептора:
def _start(self) -> None:
"""Запустить процессы акцепторов."""
for acceptor_id in range(self.flags.num_acceptors):
# Управляющий канал.
work_queue = multiprocessing.Pipe()
# Создать экземпляр акцептора, передав ему один из концов
# управляющего канала.
acceptor = Acceptor(
idd=acceptor_id,
fd_queue=work_queue[1],
# Через флаги передается класс обработчика.
flags=self.flags,
lock=self.lock,
event_queue=self.event_queue,
executor_queues=self.executor_queues,
executor_pids=self.executor_pids,
executor_locks=self.executor_locks,
)
# Запуск процесса акцептора.
# Акцептор наследует multiprocessing.Process, поэтому будет
# запущен метод run() в отдельном процессе.
acceptor.start()
self.acceptors.append(acceptor)
# Добавляется очередь для обмена с акцепторами.
self.fd_queues.append(work_queue[0])
Пулу акцепторов передается класс плагина, по умолчанию имеющий значение proxy.http.HttpProtocolHandler, то есть обработчик HTTP. Но конечно, могут быть использованы реализации прокси для разных протоколов.
Главным в акцепторе является метод accept(), который вызовет соответствующий метод у всех объектов класса socket.socket, готовых выполнить accept(), не блокируя поток:
class Acceptor(multiprocessing.Process):
"""Рабочий процесс акцептора. Акцептор ожидает прихода новых задач через
переданный ему сокет, дескриптор которого получает при запуске.
Дескриптор передается через `fd_queue`.
По умолчанию акцептор будет создавать новый поток для каждой задачи.
"""
def __init__(
self,
idd: int,
fd_queue: connection.Connection,
flags: argparse.Namespace,
lock: 'multiprocessing.synchronize.Lock',
executor_queues: List[connection.Connection],
executor_pids: List[int],
executor_locks: List['multiprocessing.synchronize.Lock'],
event_queue: Optional[EventQueue] = None,
) -> None:
super().__init__()
...
# Дескрипторы, которые используются для приема новых задач.
self.socks: dict[int, socket.socket] = {}
...
def accept(
self,
events: List[Tuple[selectors.SelectorKey, int]],
) -> List[Tuple[socket.socket, Optional[HostPort]]]:
works = []
for key, mask in events:
if mask & selectors.EVENT_READ:
try:
# Вызов реального accept() для сокета.
conn, addr = self.socks[key.data].accept()
# Добавление нового сокета в список воркеров.
works.append((conn, addr or None))
except BlockingIOError:
pass
return works
...
Следующий метод вызывается из метода run() акцептора:
def _recv_and_setup_socks(self) -> None:
# Здесь принимаются дескрипторы и создаются объекты сокетов.
for _ in range(self.fd_queue.recv()):
fileno = recv_handle(self.fd_queue)
# Для создания из дескриптора используется уже известный нам
# метод.
self.socks[fileno] = socket.fromfd(
fileno,
family=self.flags.family,
type=socket.SOCK_STREAM,
)
self.fd_queue.close()
Класс обработчика HTTP:
class HttpProtocolHandler(BaseTcpServerHandler[HttpClientConnection]):
"""Обработчик HTTP, HTTPS, HTTP2, WebSocket-протокола.
Принимает клиентское соединение и делегирует его обработку экземпляру
HttpProtocolHandlerPlugin.
"""
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
...
# Непосредственно парсер HTTP, который работает с запросом.
# Он тоже асинхронный и в процессе работы проверяет, не появились
# ли события, для чего использует select(), poll() или подобную
# функцию, за выбор которой отвечает DefaultSelector().
self.request: HttpParser = HttpParser(
httpParserTypes.REQUEST_PARSER,
enable_proxy_protocol=self.flags.enable_proxy_protocol,
)
self.selector: Optional[selectors.DefaultSelector] = None
if not self.flags.threadless:
self.selector = selectors.DefaultSelector()
# Плагин-обработчик.
# HttpProtocolHandlerPlugin — базовый класс.
self.plugin: Optional[HttpProtocolHandlerPlugin] = None
@staticmethod
def create(*args: Any) -> HttpClientConnection:
# Создать клиентское HTTP-соединение.
return HttpClientConnection(*args)
def initialize(self) -> None:
super().initialize()
if self._encryption_enabled():
self.work = HttpClientConnection(
conn=self.work.connection,
addr=self.work.addr,
)
...
Рассмотрение DefaultSelector и асинхронной обработки мы отложим до книги 2.
Обработка запросов производится следующим образом:
async def _parse_first_request(self, data: memoryview) -> bool:
# Разбор HTTP-запроса.
try:
self.request.parse(data)
except HttpProtocolException as e:
# Обработка ошибок.
...
if not self.request.is_complete:
return False
...
# Определение того, какой HTTP-обработчик применим для обработки
# данного входящего запроса.
klass = self._discover_plugin_klass(
self.request.http_handler_protocol,
)
if klass is None:
# Класс, соответствующий протоколу, не найден.
self.work.queue(BAD_REQUEST_RESPONSE_PKT)
return True
...
# Инициализация плагина.
self.plugin = self._initialize_plugin(klass)
# Обработка и получение результатов.
output = self.plugin.on_request_complete()
...
Отдельно стоит рассмотреть устройство метода shutdown() класса обработчика. В нем видно, что все замечания относительно вызова close() были учтены автором библиотеки в процессе разработки:
def shutdown(self) -> None:
try:
# Сбросить отложенный буфер только в многопоточном режиме.
# Для асинхронного режима BaseTcpServerHandler реализует
# логику must_flush_before_shutdown автоматически.
if self.selector and self.work.has_buffer():
self._flush()
# Вызвать обработчик plugin.on_client_connection_close.
if self.plugin:
self.plugin.on_client_connection_close()
conn = self.work.connection
# Если было включено шифрование, оно снимается.
if self._encryption_enabled() and \
isinstance(self.work.connection, ssl.SSLSocket):
conn = self.work.connection.unwrap()
# Отключается возможность записи в данный сокет.
conn.shutdown(socket.SHUT_WR)
logger.debug('Client connection shutdown successful')
except OSError:
pass
finally:
# Секция 4.2.2.13
# RFC 1122 "Requirements for Internet Hosts -
# Communication Layers" говорит, что close()
# приведет к немедленной отправке RST и закрытию соединения.
self.work.connection.close()
logger.debug('Client connection closed')
# Вызов приведет к вызову метода Work.shutdown(),
# который инициирует событие WORK_FINISHED.
# Это, в свою очередь, приведет к остановке
# циклов обработки событий.
super().shutdown()
Различные воркеры содержат объекты класса UpstreamConnectionPool, отвечающего за создание целого массива подключений к целевому серверу. Каждое подключение — это объект класса TcpServerConnection. Эти объекты содержат внутри сокеты для подключения к серверу.
Пул нужен, чтобы не выполнять долгие операции подключения к серверу повторно: соединения не закрываются, а многократно используются несколькими запросами.
Класс TcpServerConnection позволяет единообразно использовать обычное подключение и зашифрованный протокол HTTPS:
class TcpServerConnection(TcpConnection):
"""Буферизованное подключение к серверу."""
def __init__(self, host: str, port: int) -> None:
super().__init__(tcpConnectionTypes.SERVER)
self._conn: Optional[TcpOrTlsSocket] = None
self.addr: HostPort = (host, port)
self.closed = True
@property
def connection(self) -> TcpOrTlsSocket:
# Выполняет подключение, если оно еще не выполнено.
if self._conn is None:
raise TcpConnectionUninitializedException()
return self._conn
def connect(
self,
addr: Optional[HostPort] = None,
source_address: Optional[HostPort] = None,
) -> None:
# Здесь выполняется подключение к серверу.
self._conn = new_socket_connection(
addr or self.addr, source_address=source_address,
)
self.closed = False
Для работы по HTTPS используется метод wrap(), оборачивающий подключение в контекст TLS:
def wrap(
self,
hostname: Optional[str] = None,
ca_file: Optional[str] = None,
as_non_blocking: bool = False,
verify_mode: ssl.VerifyMode = ssl.VerifyMode.CERT_REQUIRED,
) -> None:
# Метод оборачивает подключение в контекст TLS.
# Подробнее рассмотрим его после изучения TLS.
ctx = ssl.create_default_context(
ssl.Purpose.SERVER_AUTH,
cafile=ca_file,
)
ctx.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 \
| ssl.OP_NO_TLSv1_1
ctx.check_hostname = hostname is not None
ctx.verify_mode = verify_mode
self.connection.setblocking(True)
# Теперь дескриптор сокета будет заменен дескриптором контекста TLS.
# Весь обмен через сокет будет зашифрован.
self._conn = ctx.wrap_socket(
self.connection,
server_hostname=hostname,
)
if as_non_blocking:
self.connection.setblocking(False)
Подробнее мы рассмотрим SSL и TLS в книге 3.
Реальное же соединение создается функцией common.utils.new_socket_connection(), которая вызывается в методе TcpServerConnection.connect() выше.
В ней и вызываются методы сокета:
def new_socket_connection(
addr: HostPort,
timeout: float = DEFAULT_TIMEOUT,
source_address: Optional[HostPort] = None,
) -> socket.socket:
conn = None
try:
ip = ipaddress.ip_address(addr[0])
if ip.version == 4:
# Для IPv4 создается сокет AF_INET.
conn = socket.socket(
socket.AF_INET, socket.SOCK_STREAM, 0,
)
# Установка тайм-аута, чтобы не ожидать подключения слишком долго.
conn.settimeout(timeout)
conn.connect(addr)
else:
# Для IPv6-адресов — AF_INET6, что является известным приемом.
conn = socket.socket(
socket.AF_INET6, socket.SOCK_STREAM, 0,
)
conn.settimeout(timeout)
conn.connect((addr[0], addr[1], 0, 0))
except ValueError:
pass
if conn is not None:
return conn
# Попытка создать IPv4/IPv6-подключение, если используется двойной стек.
return socket.create_connection(addr, timeout=timeout,
source_address=source_address)
Класс TCPConnection, предок класса TcpServerConnection, является абстракцией над подключением TCP. Наследуемые от него классы реализуют метод, возвращающий подключение:
class TcpConnection(ABC):
"""Абстракция подключения сервера/клиента TCP.
Основная мотивация этого класса — обеспечить управление буфером
при чтении и записи в сокет.
"""
def __init__(self, tag: int) -> None:
# Тег нужен для того, чтобы различать клиентские и серверные
# соединения.
self.tag: str = (
'server' if tag == tcpConnectionTypes.SERVER else 'client'
)
self.buffer: List[memoryview] = []
self.closed: bool = False
self._reusable: bool = False
self._num_buffer = 0
@property
@abstractmethod
def connection(self) -> TcpOrTlsSocket:
"""
Необходимо переопределить данный метод и вернуть подключенный сокет.
Это делается в потомке — классе TcpServerConnection, рассмотренном
выше.
А в классе HttpClientConnection, который является потомком
ClientConnection, данный метод только проверяет, что уже подключенный
сокет был передан объекту в конструкторе.
"""
raise TcpConnectionUninitializedException()
Кроме того, в нем производится обмен данными и реализованы необходимые для этого методы, вызывающие методы реального соединения:
def send(self, data: Union[memoryview, bytes]) -> int:
"""Отправка данных"""
# Просто вызов socket.send().
# Исключения BrokenPipeError должен обработать пользователь.
return self.connection.send(data)
def recv(
self, buffer_size: int = DEFAULT_BUFFER_SIZE,
) -> Optional[memoryview]:
"""Прием данных"""
# Вызов socket.recv().
# Исключения socket.error должен обработать пользователь.
data: bytes = self.connection.recv(buffer_size)
if len(data) == 0:
return None
# Снова ошибка форматирования строк протокола.
logger.debug('received %d bytes from %s' % (len(data), self.tag),)
return memoryview(data)
В классе имеется метод закрытия соединения и методы работы с буфером, позволяющие добавлять в него данные как в очередь и запускать отправку:
def close(self) -> bool:
"""Закрыть сокет"""
if not self.closed:
self.connection.close()
self.closed = True
return self.closed
def has_buffer(self) -> bool:
"""Вернет истину, если у подключения есть неотправленные данные"""
return self._num_buffer != 0
def queue(self, mv: memoryview) -> None:
"""В буфер подключения для отправки добавляется новая порция данных"""
self.buffer.append(mv)
self._num_buffer += 1
def flush(self, max_send_size: Optional[int] = None) -> int:
"""Отправить данные, сохраненные в буфере"""
if not self.has_buffer():
return 0
mv = self.buffer[0]
max_send_size = max_send_size or DEFAULT_MAX_SEND_SIZE
# Попытка отправить часть данных из первого буфера.
sent: int = self.send(mv[:max_send_size])
if sent == len(mv):
# Если все данные были отправлены, этот буфер возможно удалить.
self.buffer.pop(0)
self._num_buffer -= 1
else:
# Иначе указатель на данные в буфере смещается.
# Копирования данных тут не произойдет.
self.buffer[0] = mv[sent:]
del mv
logger.debug('flushed %d bytes to %s' % (sent, self.tag))
return sent
а также некоторые служебные методы, которые облегчают использование соединения с пулами воркеров:
def is_reusable(self) -> bool:
# Возможно ли переиспользовать данное соединение?
return self._reusable
def mark_inuse(self) -> None:
# Тот, кому было выдано соединение, может пометить его
# неиспользуемым.
self._reusable = False
После рассмотрения объекта соединения перейдем к изучению функционирования пула исходящих соединений.
Объект класса UpstreamConnectionPool поддерживает отдельный пул соединений для каждого сервера. По сути, этот класс — пул пулов. Внутренняя структура данных хранит ссылки на объекты соединения, которыми этот пул владеет или которые заимствовал. Заимствованные соединения не переиспользуются и не проверяются на обрыв.
Для переиспользуемых соединений пул ожидает событие чтения, наступление которого говорит о том, что соединение разорвано. Это может произойти, если пул открыл соединение к серверу, оно долго не использовалось и достигло ограничения по времени ожидания.
Когда заимствованное соединение возвращается обратно в пул, оно снова помечается как повторно используемое. Но если возвращаемое соединение уже закрыто, оно удаляется из внутренней структуры данных.
Класс и метод получения соединения:
class UpstreamConnectionPool(Work[TcpServerConnection]):
"""
Управляет пулом соединений с вышестоящими серверами.
"""
def __init__(self) -> None:
# Отображение дескриптора соединения на объект.
self.connections: Dict[int, TcpServerConnection] = {}
# Отображение адреса соединения на объект.
self.pools: Dict[HostPort, Set[TcpServerConnection]] = {}
@staticmethod
def create(*args: Any) -> TcpServerConnection:
# Просто создается новый объект соединения с сервером.
return TcpServerConnection(*args)
def acquire(self, addr: HostPort) -> Tuple[bool, TcpServerConnection]:
"""Взять переиспользуемое соединение из пула.
Если таких соединений нет, создать новое соединение
и вернуть его."""
created, conn = False, None
if addr in self.pools:
# Для этого адреса может быть несколько соединений.
for old_conn in self.pools[addr]:
if old_conn.is_reusable():
# Найдено первое, которое можно переиспользовать.
conn = old_conn
break
# Соединение не было найдено, создать и добавить новое.
if conn is None:
created, conn = True, self.add(addr)
# Выданное соединение нужно пометить как используемое.
conn.mark_inuse()
return created, conn
Метод закрытия соединения и метод возврата его в пул:
def release(self, conn: TcpServerConnection) -> None:
"""
Закрыть ранее установленное соединение.
Это приведет к отключению и закрытию сокета, а также удалению
соединения из пула.
"""
self._remove(conn.connection.fileno())
def retain(self, conn: TcpServerConnection) -> None:
"""Сохранить ранее полученное соединение в пуле для повторного
использования."""
conn.reset()
Обработка событий:
async def get_events(self) -> SelectableEvents:
"""
Установить флаг события чтения для всех переиспользуемых соединений
в пуле.
Необходимо для возможности обнаруживать изменение состояния
данного соединения. Например, его обрыв или закрытие сервером.
"""
events = {}
for connections in self.pools.values():
for conn in connections:
# Установка флага.
if conn.is_reusable():
events[conn.connection.fileno()] = selectors.EVENT_READ
return events
async def handle_events(self, readables: Readables,
_writables: Writables) -> bool:
"""
Удалить многократно используемое соединение из пула.
Когда пул является владельцем соединения, событие чтения от сервера не
ожидается.
Возникновение события означает, что либо восходящий поток
закрыл соединение, либо соединение каким-то образом достигло
недопустимого состояния.
"""
for fileno in readables:
# Удалить соединение.
self._remove(fileno)
return False
Реальное добавление и удаление соединений производится в следующих методах:
def add(self, addr: HostPort) -> TcpServerConnection:
"""
Создать новое соединение, подключить и добавить в пул.
Вместо этого метода клиент пула должен использовать метод aquire().
"""
new_conn = self.create(addr[0], addr[1])
new_conn.connect()
self._add(new_conn)
return new_conn
def _add(self, conn: TcpServerConnection) -> None:
"""Добавить новое соединение во внутреннюю структуру данных."""
if conn.addr not in self.pools:
self.pools[conn.addr] = set()
conn._reusable = True
self.pools[conn.addr].add(conn)
self.connections[conn.connection.fileno()] = conn
def _remove(self, fileno: int) -> None:
"""Удалить соединение из внутренней структуры данных."""
conn = self.connections[fileno]
try:
conn.connection.shutdown(socket.SHUT_WR)
except OSError:
pass
conn.close()
self.pools[conn.addr].remove(conn)
del self.connections[fileno]
В целом работу сетевой части библиотеки должно быть несложно понять. Сама библиотека имеет множество тонких нюансов, и мы расмотрели далеко не все из них. Это нормально для любого реального и достаточно сложного проекта. К некоторым деталям этой библиотеки мы еще будем возвращаться.
Помимо прокси существуют и другие посредники, выполняющие передачу и, возможно, обработку трафика. Например, VPN, которые выполняют инкапсуляцию.
Главное отличие между ними концептуальное. Прокси — посредник между ресурсами, заместитель, выполняющий запрос от своего имени. VPN же скрывает передаваемые данные.
Таким образом:
• Проксирование — это замещение примерно равноправным агентом, интерпретация и дальнейшая передача запроса. Сервер-посредник делает запрос вместо приложения.
• Инкапсуляция — это сокрытие. Например, упаковка в пакеты другого протокола и дальнейшая транспортировка. Точка входа для VPN отправляет данные приложения в некоторую подсеть.
Соответственно, VPN — это Virtual Private Network, то есть сеть. Схема ее работы показана на рис. 21.10. VPN-сервисы позволяют объединять сети в одну, используя виртуальные защищенные каналы поверх физических или других виртуальных. Задача VPN — передавать запросы между разными сетями либо обеспечивать единое сетевое пространство.
Рис. 21.10. Схема работы VPN
VPN-сервис перенаправляет запросы в связанную подсеть, а прокси направляет запросы на конкретные узлы. Поэтому VPN работает на канальном, транспортном или сетевом уровнях, а прокси обычно на прикладном либо транспортном уровне и иногда на сессионном, как в SOCKS.
VPN перехватывает трафик, шифрует его, отправляет в нужную подсеть и расшифровывает. Изменением трафика VPN обычно не занимается. Поэтому неправильно сконфигурированный VPN может быть причиной отсутствия доступа к сети. Неправильно же сконфигурированный прокси может быть причиной как отсутствия доступа, так и искажения трафика.
С точки зрения реализации типичный вариант реализации прокси — это прослушивающий на определенном порту транспортного протокола сервер. А типичный вариант реализации VPN — это виртуальный сетевой интерфейс, через который можно подключиться к сети.
VPN относится скорее к части канала и к ведению сетевого администратора, и потому здесь мы его не рассматриваем.
В главе 8 мы рассматривали опцию SO_NO_CHECK. Она может быть полезна для реализации VPN. Каналы, предоставляемые ею, уже покрыты различными контрольными суммами, поэтому для уменьшения накладных расходов имеет смысл отключать расчет контрольных сумм заголовков внутренних пакетов.
Необходимо понимать, что VPN использует для перенаправления трафика те же самые механизмы, что и прокси-серверы. Например, Tor предоставляет SOCKS-прокси, чтобы обеспечить работу из любого браузера через Tor-сеть, а некоторые VPN перенаправляют трафик, добавляя правила брандмауэра.
Любые данные создаются производителем и отправляются потребителю через каналы связи.
На разных уровнях в канале связи над передаваемыми данными могут производиться разные операции.
Мы рассмотрели прикладной уровень, который позволяет работать с данными на уровне протоколов высокого уровня, таких как HTTP. На этом уровне доступны подробные метаданные: кто, откуда, как и какие данные получил, а также обычно доступны сами данные. На нем работают прокси-серверы, зависимые от конкретных протоколов, и DLP-системы, предотвращающие утечки конфиденциальных данных организации.
Прокси-сервер является посредником, который получает запросы от клиентов, перенаправляет их к целевым серверам и возвращает ответы. Обычные пользователи используют прокси для доступа к ресурсам или для сокрытия своего IP-адреса от удаленных абонентов.
Компании могут использовать прокси для ограничения доступа к определенным ресурсам по адресам, разрешения доступа только для авторизованных пользователей, фильтрации содержимого, кэширования и сжатия данных для повышения скорости ответа и экономии трафика, а также для балансировки нагрузки при использовании нескольких каналов или маршрутизаторов.
Некоторые протоколы, такие как HTTP, начиная с версии 1.1, предполагают возможность наличия прокси и содержат возможности для поддержки их работы, что позволяет, например, объединять прокси в цепочки.
Существуют также протоколы исключительно для проксирования, например семейство протоколов SOCKS или прокси-протокол, которые позволяют осуществлять проксирование на транспортном уровне.
Проксирование может производиться в нескольких режимах: когда прокси явно задан в приложении или может использоваться прозрачное проксирование — автоматическое направление трафика любого приложения через прокси-сервер.
Прокси может использоваться не только для доступа к внешним ресурсам, но и для предоставления доступа к ресурсам серверов. В последнем случае прокси называется обратным, или реверс-прокси. Обратные прокси широко применяются для балансировки нагрузки, контроля заголовков, передаваемых серверам, внедрения внешней авторизации для доступа к сервисам и т.д.
Так как прокси работает на прикладном или транспортном уровне, уровни ниже остаются им не затронуты. Эту «нишу» занимают VPN, которые обычно реализуются на сеансовом, сетевом или даже канальном уровне.
Прокси, как правило, предоставляет приложениям сервис-посредник, а VPN — сетевой интерфейс. Но и у прокси и у VPN программная реализация может быть очень схожей. Главное отличие между ними принципиальное. В отличие от прокси, VPN-сервисы выполняют инкапсуляцию, скрывают данные и предоставляют общую виртуальную сеть поверх нескольких физических сетей и даже поверх сети интернет. А прокси — это сервер-посредник.
1. Что такое прокси-сервер и как он функционирует?
2. Какие преимущества дает использование прокси-сервера для пользователя?
3. Может ли прокси-сервер обеспечить безопасность сетевого трафика и защиту от внешних угроз, и если да, то как?
4. Каковы сценарии использования прокси-сервера в корпоративной среде и какие преимущества он может предоставить для бизнеса?
5. Как работают HTTP-прокси?
6. Чем различаются прямой и обратный прокси, где используется тот и другой?
7. Зачем нужен SOCKS-протокол?
8. Что такое прокси-протокол и для чего он нужен?
9. Что такое прозрачное проксирование и в чем его отличие от непрозрачного?
10. Зачем нужна опция IP_TRANSPARENT и как она работает?
11. Каковы требования для работы опции IP_TRANSPARENT?
12. В чем принципиальное различие проксирования и VPN?
13. В чем заключается механизм перенаправления трафика через VPN с добавлением правил брандмауэра?
14. Можно ли использовать VPN и прокси-серверы для одних и тех же целей? Если да, приведите пример.
15. Добавьте в пример прокси на C++ разбор IP-адресов назначения. Если IP-адрес назначения не равен локальной подсети, например 192.168.0.X, проксирование производится, в противном случае — нет.