Зачем всё это нужно?
Долгое время я терпел ограничения РосКомНадзора и соответствующие действия провайдеров по различным ограничениям доступа к сайтам — но с определённого момента устал, и начал думать как бы сделать так, чтобы было и удобно, и быстро, и при этом с минимумом заморочек после настройки… Хочу оговориться, что цель анонимизации не ставилась.
Какие вообще уже есть методы решения?
Вообще, эта проблема имеет несколько решений. Кратко перечислю самые известные и их минусы:
- Заворачивание всего трафика в VPN-туннель либо какой-то прокси — как вариант, TOR. В случае с TOR-ом о скорости можно забыть, в остальных случаях скорость и время отклика также страдают, поскольку необходимо проксировать через удалённый сервер. Поскольку РосКомНадзор у нас действует на всей территории России, получается что весь трафик придётся проксировать через зарубежный сервер, а значит время отклика (или «пинг») будет сильно страдать. Сразу отрезается весь пласт, например, игровых приложений.
- «Гибридный» вариант с использованием списков. Основной трафик идёт напрямую, но часть IP-адресов перенаправляются через VPN/прокси/TOR. Списки можно забирать, например, отсюда. Минусы — приходится периодически обновлять эти списки, и какими бы они не были актуальными есть вероятность всё же наткнуться на заблокированный сайт. На самом деле, один из лучших способов для комфортного пользования интернетом без ограничений. Но можно лучше!
- Использование «чёрной магии», связанной с особенностями применяемого провайдерами DPI-софта. Я, в общем и целом, про «дыры» в обработке трафика (например, блокирование только на уровне DNS, или пропуск необычно сформированных пакетов), и, в частности, про GoodbyeDPI уважаемого @ValdikSS. Самый большой минус — работает далеко не везде. И чем дальше, тем хуже работает. Рост вычислительных мощностей скорее упрощает жизнь провайдерам, чем нам в этом вопросе…
- …и наконец, использование DPI для обхода DPI! На самом деле этот способ является подвариантом «гибридного», но безо всяких списков. Мы анализируем пришедшие пакеты от провайдера, делаем вывод, заблокировал ли он нам что-то, и на этом основании либо пускаем дальше трафик к запросившему, либо перенаправляем трафик через VPN/прокси/TOR. Всё ещё требует конфигурации VPN/прокси/TOR, но уже не требует никаких списков, а также позволяет принимать решения на основании теоретически сколь угодно сложной логики!
Про последний способ дальше и пойдёт речь. И поможет нам в этом NGINX.
Во многом идея была вдохновлена Squid-овским механизмом HTTPS Peek and Splice, но его возможностей к сожалению не хватило.
Так ведь NGINX — это веб-сервер, а не инструмент для DPI?
NGINX — это весьма продвинутый мультипротокольный сервер, и его возможности в качестве HTTP-сервера — лишь верхушка айсберга. При этом он кроссплатформенен, имеет мало зависимостей и при этом бесконечно расширяем.
И самым лучшим расширением NGINX является проект OpenResty — который добавляет практически ко всем аспектам NGINX-а поддержку Lua.
Мне могут сейчас возразить, что современный NGINX поддерживает «изкаробки» возможность скриптинга на JavaScript (njs), и будет прав, но, во-первых, OpenResty гораздо более развитый проект и его API имеет гораздо больше возможностей, а во-вторых, OpenResty использует LuaJit с поддержкой FFI, что позволяет вызывать C-методы напрямую из Lua-мира — и это создаёт такую возможность для расширения, которая njs даже и не снилась. Во всяком случае пока что…
При этом, NGINX имеет возможность проксировать и «сырой» TCP-трафик (теоретически и UDP тоже, но я реализовал «DPI» только TCP).
О деталях реализации
Конкретно у меня по причине отсутствия кабельного интернета дома сейчас используется 4G-интернет от Мегафона, поэтому описание будет происходить с точки зрения именно Мегафона.
Подробнее как именно всё сконфигурировать будет дальше. Здесь только общее описание и логика, которой я следовал при реализации.
Прежде всего, мы поднимаем NGINX в режиме stream на каком-то порту… Пусть будет 40443. Но сам по себе nginx не знает что делать с трафиком, что туда приходит. Именно это мы и будем разруливать с помощью Lua.
Прежде всего, мы перенаправляем весь трафик с 80 и 443 порта на этот самый 40443 порт при помощи iptables и его команды REDIRECT. Эта команда интересна тем, что прописывает в свойства сокета опцию SO_ORIGINAL_DST, в которой сохраняет оригинальный IP и порт, куда пакет изначально направлялся, до того как iptables над ним зверски поиздевался, переписав destination… Кхм, я отвлёкся. Эту информацию можно извлечь при помощи getsockopt… Правда из коробки обёртки над ним не было, так что пришлось написать простенький C-модуль для nginx.
Теоретически можно было бы использовать TPROXY, и пропатчить NGINX для поддержки SO_TRANSPARENT сокетов, но хотелось не прибегать к прямому патчу исходников NGINX-а и обойтись модулями, поэтому REDIRECT.
Итак, мы запрашиваем заблокированный сайт… Пусть будет, например, rutracker.org.
…И сразу надо определиться, по HTTP или HTTPS. Несмотря на то, что HTTP постепенно умирает, всё же есть ещё сайты, использующие его. Так что обработка HTTP реализована.
HTTP — тут всё просто
Итак, мы запрашиваем http://rutracker.org. И видим, что нас перенаправило на http://m.megafonpro.ru/rkn, где Мегафон услужливо сообщает, что, мол, так и так, сайт заблокирован, просим извинить, а пока посмотрите на нашу рекламу.
Да, Мегафон просто напросто отправил 307 Temporary Redirect с Location на свой собственный сайт для отображения этого сообщения. А значит мы вполне можем отследить ровно это — 307 редирект с Location в котором http://m.megafonpro.ru/rkn.
Для этого мы вычитываем первые данные, пришедшие от клиента (вплоть до 16 кбайт, но по факту первые пакеты весьма маленькие), перенаправляем их серверу и читаем ответ от него. Если находим в нём этот редирект — это означает сразу две вещи:
- Сайт блокируется, значит этот коннект надо редиректить.
- Запрос скорее всего не дошёл до сервера, а значит переотправять его повторно — безопасно. Это необязательно верно, но верно наверное в 99% случаев. Правда, если это неверно, и вы отправляете запросы, что-то меняющие на удалённом сервере, то тут беда… Прилетит по итогу два запроса — один — тот на который заблокирован ответ, и второй — спроксированный. И узнать мы это никак не сможем. Хорошо, что HTTP без SSL становится всё меньше, правда? =)
Если редиректа нету, значит мы просто отправляем этот запрос дальше клиенту и дальше просто проксируем трафик, не вмешиваясь в него.
HTTPS вплоть до TLSv1.2 — посложнее
С SSL/TLS всё гораздо сложнее… Но есть и хорошие новости. Перед любым HTTP-запросом мы сначала должны выполнить Handshake, а значит первый пакет точно не вызовет выполнение команды на сервере в случае, если нас заблокировали, но исходный пакет таки ушёл на сервер.
Мы запрашиваем https://rutracker.org и получаем в браузере… Ошибку. Сертификат недействительный, потому что выпущен даже не для этого домена, беда-беда…
Анализируем сам сертификат… И что же мы видим? CN=megafon.ru. Получается, что для того, чтобы понять, что сайт блокируется, достаточно вычитать полученный от сервера сертификат, и если мы запрашиваем что угодно кроме megafon.ru, а получили сертификат с CN=megafon.ru — нас блокируют, и надо проксировать.
Осталось только понять, как понять, куда именно мы изначально обращались, и как получить этот сертификат.
И здесь нам поможет SNI — дело в том, что (современный, мы не говорим про эпоху IE6, она ушла и слава богу) клиент отправляет домен, к которому обращается, в составе незашифрованных данных ClientHello. Самое интересное, что эти данные умеет вычитывать даже сам NGINX из коробки — модуль ssl_preread поставляется вместе с ним. Ну а Lua-биндинги позволяют получить эту информацию и для наших целей…
Итак, что мы делаем? Процедура во многом аналогична HTTP — мы отправляем первый пакет от клиента серверу (который как раз содержит ClientHello) и ждём от сервера ответа с сертификатом. После чего убеждаемся что SNI не megafon.ru, парсим сертификат (спасибо человеку, написавшему биндинги к OpenSSL для Lua), и принимаем решение — проксировать или нет.
И всё было бы хорошо, если бы не TLSv1.3, который всю эту историю сильно обламывает…
TLSv1.3 наносит ответный удар
Во-первых, в TLSv1.3 SNI может быть зашифрованным. Хорошая новость в том, что зашифрованный SNI не расшифрует и сам провайдер, а значит он будет блокировать любые TLS-запросы к IP точно так же. Вторая особенность в том, что сертификат сервер клиенту теперь тоже отправляет в зашифрованном виде…
Проблема усугубляется ещё и тем, что Мегафон отправляет свой сертификат как раз таки по TLSv1.3, то есть зашифрованным, в случае если клиент поддерживает TLSv1.3. А все основные браузеры сейчас его поддерживают. Проблема…
На этом этапе я уже даже думал о том, чтобы патчить ClientHello, убирая из него поддержку TLSv1.3, осуществляя по сути атаку на downgrade до TLSv1.2, но вовремя почитал описание TLSv1.3. В нём реализовано аж ДВА механизма по предотвращению подобных даунгрейдов, поэтому вариант плохой.
…И здесь приходится прибегать к экстренным мерам. На самом деле этот метод я реализовал даже первым, и он по сути и является сутью метода peek and splice у Squid-а.
Мы не можем просто взять и вычитать сертификат. Поэтому мы просто открываем свой собственный коннект к серверу и пытаемся сами совершить tls-handshake. Получаем из него сертификат с CN=megafon.ru? Значит нас блокируют. Нет? Значит всё в порядке. И нам не сильно важно какой другой — да пусть даже мы дисконнект получим. Главное, что мы не получили сертификат, который является флагом блокировки.
Единственным минусом такого подхода является то, что в случае, если Мегафон начнёт поддерживать eSNI, то коннекты к его сайту будут проксироваться, но пока что SNI у него вполне незашифрованный. Да и, на самом деле, сертификат там самоподписанный отдаётся, так что можно и углубить проверку.
А что дальше с заблокированным трафиком-то делать?
Итак, мы понимаем, что сайт блокируется. Что делать? Лучшее и наиболее универсальное что я придумал — это SOCKS5 прокси. Протокол проще некуда, к нему есть удобная клиентская реализация, которую чуть доработать — и можно пользоваться. Вдобавок, SOCKS5 реализован в Tor и SSH. Поднять SOCKS5-сервер — дело пяти минут.
Особенности некоторых сайтов и приятный бонус
Во время тестов я натолкнулся на весьма странное поведение linkedin.com. Дело в том, что при его запросе почему-то я вообще не получал никакого ответа, коннект просто уходил в таймаут. Я решил, что если коннект таймаутится, есть смысл попробовать его тоже перенаправить через VPN — хуже точно не будет.
Каково же было моё удивление, когда точно такое же поведение было и через VPN. Причём, с подключением через VPN IPv4 не соединялся, а по IPv6 вполне всё работало.
Тут я вспомнил, что у SOCKS5 есть два режима подключения к удалённому хосту — по IP и по хосту. Поэтому я реализовал следующую обработку соединения (Hostname мы получаем из SNI):
direct IP => direct Hostname => socks5 IP => socks5 Hostname
С таймаутом на соединение в 2 секунды. В чём смысл? Если вдруг оказывается, что наша машина, на котором развёрнут NGINX оказывается IPv6-capable, а изначальный клиент нет, то мы сможем спроксировать трафик через IPv6, при том, что клиент будет думать что соединяется по IPv4. Аналогично и с прокси-сервером.
А что насчёт производительности?
Конкретно в моём случае, основные затраты на производительность — при первичном подключении к серверу. Но, честно говоря, даже они минимальны. После соединения потребление что CPU, что RAM остаётся почти незаметным. Конечно, Netgear R7000 достаточно мощная машинка — двухядерник с 1 GHz ядрами и 256 МБ оперативки — но он даже не нагружается на 10% во время обычного использования (активного сёрфинга, просмотра видео на YouTube). При прогоне спидтеста потребление вообще остаётся на уровне 5% CPU. Самую большую нагрузку составил как ни странно сайт по проверке замедления t.co (https://speed.gulag.link/) — вот там ядра напрягаются до 80% на одном ядре (и около 25% на другом), но при этом так и не достигают 100%.
Итак, переходим к практике — что нам для этого потребуется?
В качестве железа подойдёт почти что всё что угодно, на чем можно запустить Linux. В моём случае, я запускаю это всё на Netgear R7000 с ARM-процессором внутри, и кастомной прошивкой с версией ядра всего лишь 2.6.36. В общем и целом теоретически запустится на практически любом Linux-е с версией ядра хотя бы 2.6.32.
Поскольку решение не совсем стандартное, придётся пересобирать NGINX/OpenResty из исходников. Я успешно собирал прямо на самом роутере — занимает некоторое время, но не так чтобы бесконечное.
Конкретно нужно:
- OpenResty — это NGINX с LuaJit и основными Lua модулями. Я скачивал релизный тарболл из их раздела загрузок.
- lua-resty-openssl и его C-модуль к NGINX lua-resty-openssl-aux-module. Нужен для получения и разбора сертификатов SSL-сессий.
- Мой самописный C-модуль к NGINX и Lua-биндинг lua-resty-getorigdest-module для получения информации об IP и порте того, куда изначально обращался клиент.
- lua-struct для парсинга бинарных пакетов (в частности, поиска сертификата после ServerHello).
- Мой форк SOCKS5 Lua-клиента lua-resty-socks5 уважаемого @starius, которому была добавлена возможность соединяться через SOCKS5 не только по хостнейму, но и по IP-адресу.
- SOCKS5 прокси-сервер для проксирования заблокированного трафика — например, socks-прокси TOR-а, либо обыкновенный
ssh -D
. Для VPN-сервера — надо установить его на самом VPN-сервере и проксировать через него. Настройка socks-прокси выходит за рамки этой статьи.
Также я исхожу из того, что запускаете вы это на устройстве, маршрутизирующем ваш доступ к интернету — в моём случае это роутер. Оно должно завестись в том числе и на локалхосте, но для этого придётся садаптировать правила iptables. Также, исхожу из того, что трафик от клиентов приходит с br0 устройства.
Подготовка и сборка
Предполагаю, что сборку осуществляем в /home/username/build. Устанавливать будем в /opt/nginxdpi
- Разархивируем тарболл openresty, делаем git clone всем указанным выше дополнениям. Для удобства переименовываем папку openresty-X.Y.Z.V в openresty.
- Переходим в /home/username/build/openresty, выполняем:
./configure --prefix=/opt/nginxdpi --with-cc=gcc \
--add-module=/home/username/build/lua-resty-openssl-aux-module \
--add-module=/home/username/build/lua-resty-openssl-aux-module/stream \
--add-module=/home/username/build/lua-resty-getorigdest-module/src - Выполняем
make -j4 && make install
, ждём пока всё соберётся… - После сборки копируем:
cp -r /home/username/build/lua-resty-getorigdest-module/lualib/* /opt/nginxdpi/lualib/
cp -r /home/username/build/lua-resty-openssl/lib/resty/* /opt/nginxdpi/lualib/resty/
cp -r /home/username/build/lua-resty-openssl-aux-module/lualib/* /opt/nginxdpi/lualib/
cp /home/username/build/lua-resty-socks5/socks5.lua /opt/nginxdpi/lualib/resty/
cp /home/username/build/lua-struct/src/struct.lua /opt/nginxdpi/lualib/
Готово! Можно приступать к конфигурированию.
Конфигурация
Вся логика содержится в следующем конфигурационном файле:nginx.conf
Размещаем его в /opt/nginxdpi/cfg/nginx.conf
Создаём файл /opt/nginxdpi/cfg/start.sh со следующим содержимым:start.sh
Даём start.sh права на выполнение chmod +x /opt/nginxdpi/cfg/start.sh
, и наконец запускаем всё это добро (от рута! В принципе теоретически может заработать и без рута, но я не пробовал…):
/opt/nginxdpi/cfg/start.sh
После этого весь ваш HTTP и HTTPS трафик будет проксироваться через этот сервер.
Что можно сделать ещё?
На самом деле есть несколько вещей, которые можно доработать, разной степени сложности.
Во-первых, как я уже писал, для того, чтобы исключить проблемы с ненужным проксированием провайдерского сайта в случае если они начнут использовать eSNI, есть смысл проверять сертификат на самоподписанность — это несложно средствами OpenSSL.
Во-вторых, пока что совершенно не поддерживается IPv6. Добавить поддержку на самом деле несложно… С другой стороны, как правило IPv6 трафик сейчас фильтруется редко (если вообще где-то фильтруется).
В-третьих, опять таки не поддерживается UDP. И если на текущем этапе это не столь критично (мало что по UDP сейчас блокируется), то с развитием HTTP3/QUIC эта проблема будет гораздо более критичной. Правда как проксировать QUIC я пока понятия не имею, да и DTLS отличается от TLS… Там будет хватать своих проблем. Это, наверное, самая сложная задача.
И в завершение…
На самом деле этот механизм можно использовать и для полноценного DPI. По сути мы получаем полноценную TCP-сессию, которую можем инспектировать «на лету» — достаточно, по сути, пропатчить pipe-функцию. С другой стороны, смысла в анализе сырых данных после завершения SSL-handshake нынче мало, а зашифрованного трафика становится только больше…
Этот же метод в принципе позволяет и записывать трафик — закон Яровой исполнять, например. Надеюсь я не открыл сейчас ящик Пандоры…
…вдруг кому-то из провайдеров этот метод позволит сэкономить на DPI-софте и уменьшить цену тарифов? Кто знает.
Скажу честно, местами мне было лень обрабатывать ошибки, поэтому возможно странное поведение. Если обнаружите какие-то ошибки пишите, попробую поправить — но в принципе я сижу через этот DPI сейчас сам, и проблем особых не заметил (а те что заметил — пофиксил).
Желаю удачи в адаптации под своих провайдеров!