[Из песочницы] Локальный прокси-сервер для фильтрации браузерного трафика

?v=1

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

Первоначально была задача упростить посещение сайтов через медленное (около 5–10 кбайт/с с лагами) подключение. Тут два основных направления: 1) вырезать всё что не нужно (в первую очередь рекламу), и 2) закешировать всё что можно закешировать без особого вреда для функционала посещаемых сайтов, даже когда сами сайты не разрешают кеширование в http-заголовках, а то и явно препятствуют ему, дописывая после урлов статических файлов знак вопроса с рандомным числом.


Черновой вариант: nginx + php-fpm

Тратить на всё это время не хотелось, поэтому было решено по-быстрому настроить связку nginx + php-fpm, из которых первый разбирал входящие подключения, включая https и перенаправлял их все, без разбора хостов и урлов, в один и тот же php-скрипт. Так же была маленькая программа на C, которая конвертировала http-proxy-протокол в обычный http (s)-трафик. То есть превращала запросы GET http://host/path HTTP/1.x в GET /path HTTP/1.x (имя хоста всё равно есть в Host-заголовке) и проксировала все https CONNECT’ы на локальный nginx. Как потом выяснилось, убирать http://host из http-запросов было не обязательно, nginx их принимает так же как и обычные.

PHP-скрипт, в свою очередь, собирал назад разложенный по разным переменным запрос, через fsockopen () подключался к целевому серверу (благо там поддержка SSL встроенная), слал собранный http-запрос, забирал ответ и отправлял его браузеру с помощью header () и echo. Скрипт делался не совсем с нуля, спасибо некоему Évelyne Lachance за https://github.com/eslachance/php-transparent-proxy (но всё же то, что по ссылке, имеет несколько другую цель и поэтому просто скопировать его было нельзя).


Тут возникли две проблемы

Во-первых, если собрать сам исходный URL труда не представляло, то содержимое POST-запросов собирать было уже сложнее, а если там ещё и необычный Content-Type и отправка файлов — то php-шный парсер почти необратимо терял исходные данные, собрать всё это назад из массива $_POST[] и уже залитых в серверную файловую систему загруженных файлов, ничего не потеряв, было почти нереально (по крайней мере по моей оценке). Но спасла положение весьма кстати появившаяся в php 5.4 опция enable_post_data_reading — если её поставить в off, то тело запроса парситься не будет и его можно будет просто прочитать блобом из файла 'php://input'. При этом массивы $_POST и $_FILES, естественно, не будут инициализироваться, но они нам и не нужны.

Во-вторых, nginx не умеет генерить сертификаты на лету из коробки, а настройку «игнорировать все проблемы с сертификатами» я в firefox не нашёл. Проблема была на тот момент решена патчем браузера, заставлявшим его любой сертификат считать доверенным и подходящим. Наверно были и другие способы это сделать, но все они казались сложнее. Патч естественно был кривой и сделанный почти методом тыка (желания изучать весь браузерный код проверки сертификатов не было), но он делал своё дело, а больше было и не надо.

Между сборкой исходного запроса и отправкой проксированного тривиальным образом были добавлены два куска кода. Один проверял, что host или host/path не из чёрного списка (организованного в виде отдельного php-файла с массивом внутри). Второй проверял, нет ли страницы в кеше (если есть — отдаётся из кеша). Если страницы в кеше нет, то делается проксированный запрос. Если запрос был GET и статус ответа 200, то полученная страница — кандидат на запоминание в кеше. Запоминать или нет — решалось функцией, которая по хосту, урлу (там просто были прописаны конкретные адреса, которые меня интересовали и которые я знал что надо кешировать) и content-type выносила вердикт. Кешировалось немного: http-статус, content-type и тело ответа. И при отдаче из кеша, соответственно, отдавались только эти данные, а все остальные заголовки, которые были в исходном ответе, безвозвратно терялись.

Вот такая грубая и костыльная реализация, на удивление, вполне себе работала, хотя конечно артефакты её присутствия время от времени проявлялись и раздражали.

В следующие полгода использования система дорабатывалась под решение возникающих проблем (как проблем из-за общей её кривости, так и проблем — возникающих задач).

Ещё через 3–4 месяца мне надоело патчить браузер после каждого апдейта и было решено сделать всё по-нормальному — nginx и php выкидываем и пишем всё на C.


Итоговый вариант

Маленькая программа (из старой версии), которая разбирала и перенаправляла прокси-протокол, была взята за основу. Первоочерёдной задачей было реализовать в ней разворачивание https, потому что всё остальное в общем-то уже известно как делать (делалось не раз в другим проектах), а с SSL где-то кроме php я на тот момент ни разу не работал.

Для этого взял библиотеку OpenSSL. Весь код, её использующий, было решено выделить в отдельные процессы. Потому что, во-первых, в ней тогда постоянно находились дыры (а межпроцессная изоляция может эту проблему иногда сгладить), во-вторых так проще делать (модульность и инкапсуляция на уровне сокетов), и в-третьих API OpenSSL местами весьма странное (мягко говоря), и для нейтрализации последствий то ли кривого API, то ли неправильного его применения (вследствие отсутствующей или двусмысленной документации) удобно просто убивать ssl-процесс после того как соединение завершилось (к сожалению, это плохо сказывается на производительности, но я с этим смирился).


Жалобы на OpenSSL и на SSL

Вообще, из-за регулярных уязвимостей и невнятного API я даже собирался написать свою реализацию TLS, и даже начал, но, дойдя до парсинга сертификатов (увы, без этого почти ничего не сделать, ибо сертификаты оказывается используются не только для защиты от подмены сервера, но и для согласования ключей, даже если защита от подмены не нужна), испытал ещё один дискомфорт от спецификации ASN1. Стало понятно, что API библиотеки это только вершина айсберга, а внутри оно всё такое уже начиная с RFC самого протокола. Хотя в последнее время я более менее с этим всем смирился.


SSL/TLS-обёртки

Итак, были сделаны две вспомогательные программы, одна для заворачивания клиентского нешифрованного сокета в SSL/TLS, и вторая для разворачивания зашифрованного сокета, принятого сервером, в незашифрованный. Принцип простой: у подпрограммы есть наследуемые от родительской программы файловые декрипторы, которые родительская программа может заранее открыть нужным образом так, что дочерний процесс будет выглядеть для неё просто сокетом, в который можно записывать и из которого можно читать. При этом практически никакого отличия этого межпроцессного сокета от сокета интернетного не будет.

Таким образом, сервер принимает (accept) входящее подключение, выясняет что на той стороне хотят SSL/TLS, после чего отдаёт соединение дочернему процессу (который поднимает SSL/TLS-сессию, генерирует сертификат если нужно или берёт уже ранее сгенерированный), а от дочернего процесса берёт межпроцессный сокет, через который передаются уже нешифрованные данные. В итоге весь дальнейший код может вообще не делать различий между http-соединением и https-соединением, прозрачно расшифровывающимся дочерним процессом, и использовать для работы с этими соединениями обычные функции для работы с сокетами.

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

Программы названы ssl-server-wrapper и ssl-client-wrapper. Кстати, второй можно запустить и отдельно вручную — тогда нешифрованный канал будет направлен в терминал, откуда его запустили, а установить SSL-соединение можно, набрав несколько простых команд.


Пример

Пишем

$ host ya.ru                        - узнаём ip-адрес
ya.ru has address 87.250.250.242
$ ./ssl-client-wrapper
T/etc/ssl/certs/ca-certificates.crt    - указать место где хранятся доверенные RootCA
C87.250.250.242:443/ya.ru              - подключиться по указанному адресу с указанным SNI

Ответ (много отладочных логов, можно скомпилировать чтобы было без них): дамп сертификата и предоставленной цепочки

DEBUG: cert[-1] subject = /C=RU/O=Yandex LLC/OU=ITO/L=Moscow/ST=Russian Federation/CN=*.yandex.az
DEBUG: cert[-1] issuer = /C=RU/O=Yandex LLC/OU=Yandex Certification Authority/CN=Yandex CA
DEBUG: cert[0] subject = /C=RU/O=Yandex LLC/OU=ITO/L=Moscow/ST=Russian Federation/CN=*.yandex.az
DEBUG: cert[0] issuer = /C=RU/O=Yandex LLC/OU=Yandex Certification Authority/CN=Yandex CA
DEBUG: cert[1] subject = /C=RU/O=Yandex LLC/OU=Yandex Certification Authority/CN=Yandex CA
DEBUG: cert[1] issuer = /C=PL/O=Unizeto Technologies S.A./OU=Certum Certification Authority/CN=Certum Trusted Network CA
DEBUG: cert[2] subject = /C=PL/O=Unizeto Technologies S.A./OU=Certum Certification Authority/CN=Certum Trusted Network CA
DEBUG: cert[2] issuer = /C=PL/O=Unizeto Sp. z o.o./CN=Certum CA

Попытка собрать trusted цепочку

DEBUG: cert[-1] issued by cert[1]
DEBUG: cert[1] issued by cert[2]
DEBUG: cert[2]=cert[*0] was not issued by something from trusted-cert-store - chain incomplete, trying to recover
DEBUG: cache miss at ./cert-pin/cache/9nqv0qm41tlxyel95bbods0famxjagbx.0
DEBUG: downloading AIA issuer cert: http://repository.certum.pl/ca.cer
DEBUG: cert[*0] issued by cert[*1]
DEBUG: cert[*1] is self-signed, chain ended

Как видно, «Certum CA» нет в списке доверенных Root CA, поэтому на первый взгляд это обрыв цепочки, но во втором сертификате цепочки (cert[2]) нашлась ссылка http://repository.certum.pl/ca.cer откуда можно скачать этот неизвестный сертификат; сертификат скачан (cert[*1]), но оказался самоподписанным и таким образом ничему не помогающим на самом деле, в Root CA есть «Certum Trusted Network CA», так что всё в порядке и можно было ничего не скачивать.

Проверка соответствия доменного имени:

DEBUG: check wildcard '*.yandex.az' against needed name 'ya.ru'
DEBUG: check wildcard '*.yandex.tm' against needed name 'ya.ru'
DEBUG: check wildcard '*.yandex.com.ua' against needed name 'ya.ru'
DEBUG: check wildcard '*.yandex.de' against needed name 'ya.ru'
DEBUG: check wildcard 'yandex.jobs' against needed name 'ya.ru'
DEBUG: check wildcard '*.yandex.net' against needed name 'ya.ru'
DEBUG: check wildcard '*.xn--d1acpjx3f.xn--p1ai' against needed name 'ya.ru'
DEBUG: check wildcard '*.yandex.com.ge' against needed name 'ya.ru'
DEBUG: check wildcard 'yandex.fr' against needed name 'ya.ru'
DEBUG: check wildcard '*.yandex.fr' against needed name 'ya.ru'
DEBUG: check wildcard 'yandex.kz' against needed name 'ya.ru'
DEBUG: check wildcard 'yandex.aero' against needed name 'ya.ru'
DEBUG: check wildcard '*.yandex.jobs' against needed name 'ya.ru'
DEBUG: check wildcard '*.yandex.ee' against needed name 'ya.ru'
DEBUG: check wildcard 'yandex.com' against needed name 'ya.ru'
DEBUG: check wildcard 'yandex.tm' against needed name 'ya.ru'
DEBUG: check wildcard 'yandex.ru' against needed name 'ya.ru'
DEBUG: check wildcard '*.yandex.ru' against needed name 'ya.ru'
DEBUG: check wildcard 'yandex.lv' against needed name 'ya.ru'
DEBUG: check wildcard '*.yandex.lt' against needed name 'ya.ru'
DEBUG: check wildcard 'yandex.az' against needed name 'ya.ru'
DEBUG: check wildcard 'yandex.net' against needed name 'ya.ru'
DEBUG: check wildcard 'yandex.lt' against needed name 'ya.ru'
DEBUG: check wildcard 'ya.ru' against needed name 'ya.ru'

Итог:

DEBUG: protocol = TLSv1.2
DEBUG: cipher = ECDHE-RSA-AES128-GCM-SHA256
DEBUG: verify_result = 0
DEBUG: server_cert subject: /C=RU/O=Yandex LLC/OU=ITO/L=Moscow/ST=Russian Federation/CN=*.yandex.az
DEBUG: server_cert issuer: /C=RU/O=Yandex LLC/OU=Yandex Certification Authority/CN=Yandex CA
OK: connection to 87.250.250.242:443/ya.ru established

Теперь есть прозрачное подключение, шлём http-запрос и получаем ответ

GET / HTTP/1.0
Host: ya.ru
Connection: close

HTTP/1.1 200 Ok
Accept-CH: Viewport-Width, DPR, Device-Memory, RTT, Downlink, ECT
Accept-CH-Lifetime: 31536000
(...)


Основной функционал

С шифрованием разобрались, теперь можно приступить к обработке полезной нагрузки.

Так как nginx у нас больше нет, то парсинг запроса придётся реализовывать заново. В целом ничего сложного тут нет, сначала парсятся заголовки через \r\n или \n разделитель, до тех пор пока не встретится пустая строка, а затем, если был заголовок Content-Length, читается тело запроса указанного количества байт. Chunked encoding в запросах можно и не поддерживать — никакие нормальные браузеры, да и вообще http-клиенты, его не генерируют.

После того, как запрос распарсен, он попадает в обработчик запроса — это та часть, которая раньше была написана на php, весьма увеличившаяся в размерах и количестве файлов. И хотя списки правил (кого блокировать, кого кешировать, кому резать куки и т.д.), ранее бывшие оформлены в виде php-массивов и коротких скриптовых функций, были переделаны в отдельные текстовые файлы специального удобного формата, но часть «пользовательской» логики всё ещё защита в коде (только теперь на C, а не PHP), а код в целом унаследовал много признаков «сделать побыстрее как-нибуть» от своего предшественника. Сейчас обработчик (кроме капитально переделанных списков правил) всё так же сделан в виде одного «потока» кода из «главного» файла и вставленных в него include (не заголовочных файлов, а сам код).

Изначально функционал обработчика был даже чуть меньше чем функционал последней из старых версий, но за 2 года плавно набрал ещё много нового. Из последнего — последние полгода-год всё чаще стали попадаться сайты с неполными цепочками сертификатов (потому что браузеры теперь их поддерживают без ругани), сначала я их просто вносил в белый список исключений, но это стало происходить слишком часто и пришлось реализовать кеширование и докачивание недостающих сертификатов в цепочке.


Что получилось


  1. Отдельные модули для разворачивания шифрованного SSL/TLS-туннеля, один из которых при желании можно использовать как самостоятельную программу типа ssl-telnet. Ещё можно заменить их на какие-то альтернативные реализации (например с другой крипто-библиотекой), при этом остальная часть прокси ничего не заметит. Аналогично с инкрементальными обновлениями, ничего даже не нужно перезапускать. А ещё можно их использовать как демки по работе с OpenSSL (я прочёл много документации и всяких обсуждений в процессе их написания, возможно кто-то сможет теперь всё это быстрее усвоить). Код старался делать максимально понятным и простым.


    • У клиента есть хранилище сертификатов для: белый список по сайтам (принимать и не проверять), чёрный список по сайтам (не проверять и не принимать), список вопросов (те, которые не входят ни в белый, ни в чёрный и не прошли валидацию — аналог браузерной страницы с предложением внести подозрительный сертификат в список исключений). Для сайтов можно включать режим белого списка (то есть принимать только сертификаты из него, остальные считать недоверенными). К сожалению, всё это делается путём манипуляций над файлами, интерактивного приложения пока нет.
    • Серверная часть сама генерирует сертификаты ко всем сайтам на лету, ей нужен только корневой сертификат, добавленный как trusted root CA в браузер.

  2. Возможность пробросить точку выхода в интернет через, например, ssh port forwarding (связанный с этим функционалом код расположен в helpers/remote.c и в proxy.c в месте где обрабатывается аргумент »--proxy». Это не тоже самое что запустить само прокси на удалённом сервере. Тут важно что кеш и расшифровка SSL/TLS находятся на локальном компе, а пробрасывается только «внешний» конец. Хотя я давно не проверял работу этого режима, могла и сломаться.


  3. Единообразный формат файлов правил для: блокировки загрузки страниц, блокировки куков, управления кешированием, включения сниффера, включения raw-режима (для real-time стримингов).


  4. Аналог /etc/hosts, но усовершенствованный: возможность сопоставления айпи-адреса не только по хосту, но и по порту и по протоколу, а так же сменить порт и протокол подключения.


  5. Кеш, управляемый прописанными ему настройками, а не заголовками, присланными сервером. Заголовки управления кешированием часто не отражают действительность (или отражают мнение владельца сайта, которое может расходиться с критериями удобства пользования этим сайтом), поэтому сейчас они игнорируются. Думаю что стоит сделать их учёт по принципу «если сайт разрешил кешировать то значит можно, а если нет — разбираемся сами можно или нельзя», но пока руки не дошли. Кеш хранится в интуитивно понятно организованном дереве директорий, хранится бессрочно, но можно либо вручную удалять отдельные сохранения, либо настройкой задать «игнорировать всё что сохранено раньше такого-то времени».


    • Кроме кеша есть ещё сохранения — это те страницы, которые не берутся с диска при запросе к ним, но на всякий случай всё равно сохраняются. При включении оффлайн-режима они тоже смотрятся (в файл offline.flag надо прописать предпочтительное время, за которое будут искаться сохранённые страницы при обращении к ним). При настройке сохранений учтите, что, несмотря на то, что сохраняются только ответы (содержимое POST-запросов не сохраняется), в них всё же могут быть некоторые данные, которые стоит беречь от кражи, а сохранённые на диске страницы — дополнительный вектор атаки.

  6. Возможность кастомных действий перед, после или вместо загрузки страницы проксированным запросом. Реализваны хардкодом, файл handle/inc.rewrite.c


  7. Есть dashboard (двоичный файл со статусами всех потоков прокси) и программа для его вывода на экран в удобном виде.
    ./dashboard --basedir=/path/to/proxy/base --highlight --loop


  8. Любой аспект работы прокси можно легко переделать при необходимости. Код, несмотря на то, что он весьма разросся по сравнению с первыми версиями, всё ещё занимает очень мало места, и не потребует много времени как на первоначальное изучение, так и на освежение в памяти забытых деталей. С другой стороны, кому-то это и минус — для пользования всеми возможностями программы предполагается её регулярная пересборка и правка кода, а не «скомпилировал и удалил исходники».


Плюсы и минусы
(+) Модульность
(-) Из-за изоляции OpenSSL работает медленнее чем могло бы
(+) Гибкая настраиваемость под конкретные нужды
(±) Много настроек прямо в исходном коде в виде #define и не только
(±) IPv6 не поддерживается
(-) Нет интерактивного приложения для настройки и управления
(-) Код местами весьма плох и костылен
(-) До сих пор нет поддержки Access-Control-Allow-Origin с не '*' в закешированных ответах —, но единственное что у меня от этого ломалось это интерфейс мэйлру почты.
(-) До сих пор нет поддержки учёта настроек кеширования, присылаемых сервером
(-) Скачивать через него большие файлы (не в raw режиме) неэффективно — сначала прокси скачает файл себе на диск, затем будет через localhost-сеть отдавать его браузеру, который опять будет сохранять его на диск, а ещё там где-то (а может и в нескольких местах) прописано ограничение в 10 мбайт на запрос


Как собрать/установить и технические детали кода

Из обычных библиотек используются стандартная C-библиотека, zlib (для распаковки gzip http-ответов), openssl (линкуется только к ssl-подпрограммам). Так же используются свои библиотеки (маленькие) с кодом который мне часто оказывается нужен в разных местах и поэтому вместо регулярного копипаста выделен в отдельные компилируемые единицы.

В целях данной публикации, для упрощения всё это собрано в один пакет (скачать можно тут) и обёрнуто в один сборочный скрипт (да, не Makefile, так уж вышло что пока я обхожусь без make) build-all.sh.

Ряд настроек расположен в исходниках: в скрипте fproxy/build2.sh, в файле fproxy/config.h (если у вас файл с бандлом доверенных центров сертификации расположен не в /etc/ssl/certs/ca-certificates.crt то надо этот путь заменить тут). Настройки подпрограмм (helpers/) расположены прямо в их коде вверху в виде #define (config.h они не используют), но их менять вряд ли кому потребуется.

То, что некоторая часть настроек прописана в компилируемом коде, а не в файлах настроек сделано для (моего) удобства. В файлах настроек настраивается то, что можно назвать наиболее часто меняющимися пользовательскими настройками (списки правил), а всё остальное — в общем то первоначальная настройка программы при её установке, и крайне редкие изменения всяких технических параметров (даже чаще меняется логика работы кода чем #define-настройки). По факту сейчас вполне рабочая версия получается если просто запустить build-all.sh и не трогать никакие исходники перед этим.

Но переделать код на приём настроек через командную строку или из ещё одного файла конфигурации несложно.

Для работы прокси нужна заранее подготовленная структура директорий. Для её создания есть скрипт prepare-dir.sh — он создаёт рядом подготовленную директорию fproxy-target/ со всем что требуется и кладёт туда примеры правил (в том числе много блокировок спамных доменов).


Насчёт прав доступа и вообще безопасности

Простейший вариант установки — запустить всё из под своего обычного юзера с обычными правами на всё (можно даже прямо из директории fproxy-target/, созданной скриптом prepare-dir.sh).

Но по ряду причин имеет смысл сделать по-другому. Для прокси создаётся отдельный юзер и группа, ему выдаются эксклюзивные права на чтение данных прокси, и права на запись в несколько мест, где они нужны. Права «только чтение» можно реализовать, поставив владельца root и группу прокси, и поставив доступ 0640 или 0750. Делать что бы то ни было из данных world-readable в любом случае плохая идея, всё же там лежит вся история браузера, а где-то и сохранённое содержание страниц, а так же ключи, с помощью которых браузеру можно делать MITM.

Для работы программы dashboard ей нужен только доступ на чтение к файлу BASE/log/dashboard и ничего другого.

По умолчанию прокси слушает 127.0.0.10:3128. Для работы с 127.0.0.10 у меня ничего специально настраивать (в ОС) не пришлось, но возможно в каких-то системах нужно.

Рабочая директория по умолчанию — та, что была текущей при запуске, но это можно поменять в настройках.


Заключение

Ещё раз ссылка на скачивание: тут.

Для публикации код был дооформлен в такой вид, который на мой взгляд допустимо публиковать, но всё же делалось это исключительно для себя под локальное применение и «по-быстрее», так что сейчас код во многих местах не образец для подражания. Но основную задачу он выполняет, а со временем будет улучшаться.

Из наиболее приоритетных на данный момент планов на будущее в плане функционала — сделать интерактивную настройку и управление (сертификаты, кеш, история, менеджер загрузок больших файлов) либо отдельным приложением, либо в спецдомене через браузер, но ничего не обещаю.

Надеюсь, что кому-то это всё окажется интересным и так или иначе поможет.

© Habrahabr.ru