Когда на мобильном интернете включают белые списки, обычного VLESS уже не хватает. Работают туннели через звонки VK, DNS-туннели, S3 и другие нестандартные транспорты, но почти у каждого из них своё отдельное приложение. Rawline объединяет их в одном клиенте на sing-box и Xray.
Расскажу, почему он сделан как форк, а не с нуля, как в нём уживаются транспорты, которые никогда не задумывались как часть одного ядра, и какие проблемы безопасности многих VPN-клиентов в нём закрыты.
Что такое Rawline
Rawline это клиент на ядрах sing-box и Xray для Android, Windows, Linux и macOS:
RawlineMobile для Android 7.0 и новее;
RawlineDesktop для Windows, Linux и macOS.
Кроме всего, что умеют sing-box и Xray (VLESS, VMess, Trojan, Shadowsocks, Hysteria2, TUIC, WireGuard, AmneziaWG, OpenVPN, OpenConnect, SSTP и так далее), клиент запускает транспорты, которые обычно живут в собственных приложениях:
Тип | Что поддерживается |
|---|---|
Через звонки VK и MAX, WebRTC, TURN | MRTT, qWDTT, CSQTT, WDTT Plus, FreeTurn, olcRTC, OpenFlux |
DNS-туннели | DNSTT, VayDNS, SlipStream, MasterDNS (и StormDNS), CottenDNS, Soar |
Обфускация WireGuard | wg-obfuscator, Phobos, wgobfs |
Прочее | S3 Bypass, TrustTunnel |
Всё это работает как обычные профили: импорт по ссылке или QR, подписки, тест пинга, цепочки, раздельная маршрутизация по приложениям и доменам.
Зачем это понадобилось
Когда ограничения стали заметными, у меня на телефоне оказалось несколько клиентов сразу. VLESS в одном, туннель через звонки VK на случай белых списков в другом, DNS-туннель в третьем, на случай если перестанет работать и это. У каждого свой формат ссылок, свой выбор приложений и свой тест скорости, а цепочку из профилей разных приложений не собрать вообще. Когда один способ перестаёт работать, приходится открывать другое приложение и вспоминать, как там всё устроено.
Мне хотелось простого: открыть список профилей, ткнуть в рабочий и не думать, на каком ядре или в каком процессе он живёт. Заодно, раз все профили лежат в одном месте, их можно прогнать одним тестом пинга, сложить в цепочку и разложить по общим правилам маршрутизации.
Почему форк Throne, а не своё ядро
Писать сетевое ядро с нуля смысла не было. За sing-box и Xray стоят годы работы над TLS-фингерпринтами, DNS, маршрутизацией, UDP, совместимостью платформ и тысячей пограничных случаев. Ядро, написанное одним человеком, не стало бы ни быстрее, ни безопаснее. Оно было бы просто моложе, и найденных в нём ошибок было бы меньше только потому, что их ещё никто не искал.
Весь базовый функционал с нуля писать тоже не хотелось: подписки, редактор профилей, выбор приложений и маршрутизация давно есть в открытых клиентах. Поэтому за основу взят Throne на десктопе и ThroneForAndroid на Android, у которых длинная родословная через SagerNet и NekoBox. Выбор пал на Throne, потому что он умеет держать sing-box и Xray одновременно и строить между ними мосты, а мне это было нужно для цепочек.
Интерфейс при этом от Throne не достался. В мобильной версии он полностью переработан, на ПК новый интерфейс тоже на подходе, но там стандартным останется классический вариант.

Как это устроено

Одно ядро на две платформы
Сетевая часть, RawlineCore, написана на Go и живёт в десктопном репозитории. На компьютере она работает отдельным процессом, а на Android та же кодовая база собирается через gomobile в библиотеку и работает внутри процесса приложения. sing-box и Xray поднимаются там же, без gRPC API, которым можно было бы управлять снаружи. Мобильная версия держит ядро на закреплённом коммите, поэтому обновить ядро можно только осознанно, случайно оно не подтянется.
Генератор конфига на Android переписан на Kotlin с десктопного C++. Звучит как лишняя работа, но так один и тот же профиль даёт на телефоне и на компьютере одинаковый конфиг. У перенесённых функций в комментариях стоят ссылки на строки десктопного исходника, и после изменений на одной стороне поведение легко сверить с другой.
Транспорты, которых нет в sing-box
Вариантов было два: переписывать каждый транспорт под внутренние API sing-box или запускать оригинальный клиент проекта и подключаться к нему. Почти везде я выбрал второе. Переписанный транспорт начинает отставать от оригинала с первого же обновления, а оригинальный клиент чинит его автор.
Схема такая: нативный клиент поднимает локальный SOCKS5, ядро ходит в него обычным outbound, а дальше работают маршрутизация, цепочки и тест пинга. Для пользователя это просто ещё один профиль.
На Android у такого подхода есть несколько неочевидных мест.
Первое касается запуска. Начиная с Android 10 приложение не может исполнять файлы из своего записываемого каталога. Поэтому клиенты упакованы в APK как lib*.so, система сама распаковывает их в nativeLibraryDir, и оттуда запуск разрешён. Отсюда useLegacyPackaging = true в сборке.
Второе касается архитектур. Каждый клиент собирается под arm64-v8a, armeabi-v7a и x86_64. Часть проектов написана на Go, часть на Rust (slipstream и половина CSQTT), и у каждого свои сюрпризы при кросс-компиляции.
Третье касается жизненного цикла. Процесс нужно запустить, дождаться готовности (причём не «порт открыт», а «авторизация прошла»), корректно остановить при смене профиля или сети и не оставить висеть в фоне.
Закреплённые версии и контрольные суммы
Когда в APK лежит полтора десятка чужих бинарников, главным становится вопрос «а что именно мы собрали». Нативные клиенты собирает отдельный workflow (carriers.yml) из закреплённых коммитов оригинальных репозиториев. Архивы и их SHA-256 записаны в native-transports.lock.json, а scripts/stage_native_transports.py перед сборкой APK скачивает их и сверяет хэши, исходные коммиты и состав файлов. Если поменялся патч или скрипт подготовки, а сборку не обновили, CI это заметит.
Не только подключение
Для туннелей через звонки одной ссылки мало. В приложении можно привязать аккаунт VK или MAX, и звонки для MRTT, qWDTT, CSQTT и WDTT Plus будут создаваться автоматически. Вход происходит во встроенном окне браузера, отделённом от остального клиента. Страница может открывать только сайты VK или MAX, доступа к файлам, камере, микрофону и геолокации у неё нет, а профили, адреса серверов и остальные данные клиента ей просто не видны. Клиент забирает со страницы только токен, а для MAX ещё и ID группы. Сервер MRTT, qWDTT, CSQTT, WDTT Plus или FreeTurn можно развернуть на VPS по SSH прямо с телефона. Отпечаток ключа сервера показывается при первом подключении и запоминается, скачиваемые компоненты проверяются по SHA-256, а в конце приложение выдаёт готовую ссылку или QR-код. MRTT, к слову, мой собственный транспорт. Остальные проекты принадлежат другим авторам, все они перечислены в README.
Как приложение вне VPN узнаёт адрес сервера
Пока я собирал всё это вместе, на Хабре вышла серия статей о том, что многие популярные VPN-клиенты сливают адрес сервера приложениям, которые пользователь сам исключил из VPN.
Типичная настройка: VPN включён, а банковское приложение и маркетплейс исключены из него и ходят напрямую. Логично ожидать, что эти приложения ничего не знают о вашем VPN. Но если такому приложению удастся отправить хоть один запрос через туннель на сервис вроде «покажи мой IP», адрес сервера у него в руках, а дальше он может уйти куда угодно, в том числе в список на блокировку.
Сделать это можно двумя разными способами, и защита от одного никак не помогает от другого. Я проверил свой клиент на оба и попутно нашёл третий.

Утечка через локальный прокси
Многие клиенты на Xray и sing-box поднимают на телефоне прокси-сервер SOCKS5 по адресу 127.0.0.1, обычно на порту вроде 10808, и через него гонят трафик в ядро. Пароля у этого прокси нет. Проблема в том, что 127.0.0.1 на Android общий для всех приложений. Любое приложение, даже исключённое из VPN, может подключиться к этому порту, и его запрос уйдёт через сервер. Системные настройки VPN тут вообще не участвуют: приложение не просит у Android доступ к туннелю, оно просто открывает TCP-соединение.
Подробно это разобрал runetfreedom, а Wendor показал на практике и предложил закрыть прокси паролем. Случайного порта без пароля недостаточно, потому что перебрать 65 тысяч локальных портов можно за секунды.
В Rawline сделано так. В режиме VPN SOCKS-, HTTP- и DNS-входов нет вообще. Трафик принимает TUN внутри sing-box, без tun2socks и промежуточного прокси. Если слушателя нет, то и подключаться не к чему.
Внутренние мосты, без которых не обойтись (sing-box ↔ Xray и ядро ↔ нативный клиент), слушают только loopback. Порт и логин с паролем из 32 случайных символов (SecureRandom) генерируются заново при каждой сборке конфига. Xray при включённом пароле не принимает SOCKS4, в котором пароля нет в принципе.
Пользовательские конфиги проверяются до запуска. Если в полном конфиге Xray или sing-box есть API управления (HandlerService, V2Ray API) на открытом порту или SOCKS-вход без пароля, такой конфиг отклоняется. Подписка тоже не может снять защиту: заголовки вроде socks-auth-mode к локальной авторизации не применяются.
На каждый пункт есть тест. VpnListenerPolicyTest проверяет отказ для открытых API, SOCKS без пароля и слабых паролей. AndroidVpnListenerTest следит, чтобы в VPN не появлялись прокси-слушатели даже со старыми настройками. LocalProxySecurityTest делает настоящее SOCKS-рукопожатие с обоими ядрами и проверяет, что анонимный клиент получает отказ.
Утечка через tun0
Второй способ обходится без всякого прокси. Когда VPN включён, в системе появляется сетевой интерфейс tun0, через который трафик попадает в клиент. Android решает, кого пускать в туннель, через правила маршрутизации. Но приложение может вызвать setsockopt(SO_BINDTODEVICE, "tun0") и привязать свой сокет к туннелю напрямую, в обход этих правил. Пакет попадает в клиент, а тот честно отправляет его на сервер. Пароль на SOCKS тут ничего не меняет, потому что пакет идёт мимо любого прокси.
Эту проблему проверил на 28 клиентах LuciusWill, а в отдельном разборе описал решение для AmneziaVPN. Для каждого соединения из туннеля нужно спросить у Android, какому приложению оно принадлежит (ConnectivityManager.getConnectionOwnerUid()). Если владелец неизвестен, соединение надо отклонять, а не пропускать.
В Rawline для этого есть правило маршрутизации sing-box, которое стоит первым после служебных мостов:
// SO_BINDTODEVICE lets an app outside the VPN send through tun0 and read the server's address;
// Android reports no owner for its socket. System services have a UID but no package.
if (settings.blockUnownedTun && settings.vpnMode && !state.forTest) rules.add(jsonObjectOf(
"type" to "logical", "mode" to "and", "action" to "reject",
"rules" to JsonArray.of(
jsonObjectOf("inbound" to JsonArray.of(Tags.TUN_IN)),
jsonObjectOf("package_name_regex" to JsonArray.of(".*"), "invert" to true),
jsonObjectOf("user_id" to JsonArray.of(0, 1000, 1001, 1002, /* … */ 9999), "invert" to true),
),
))
Если соединение пришло из TUN, у него нет имени пакета и UID не системный, оно отклоняется. Когда Android не находит владельца, правило срабатывает. Список системных UID нужен потому, что у системных служб (root, system, DNS-резолвер, сетевой стек) UID есть, а пакета нет. Без этого исключения сломался бы, например, системный DNS.
В настройках опция называется «Скрывать туннель от приложений вне VPN», она включена по умолчанию и работает на Android 10 и новее.
Третья утечка: чужой первый пакет
Для защиты от первой утечки часто советуют включить пароль на SOCKS5 и заодно выключить в нём UDP. Выключать UDP мне не хотелось, без него ломаются звонки, игры и QUIC. Поэтому я разобрался, как UDP устроен в компонентах, которые использует клиент.
В SOCKS5 для UDP есть команда UDP ASSOCIATE. Клиент по TCP проходит авторизацию, просит UDP-канал, и прокси отвечает: «шли датаграммы на такой-то порт». Пароль при этом проверяется только в TCP-части. У самих UDP-пакетов есть лишь адрес отправителя, а на 127.0.0.1 он у всех приложений одинаковый.
С закреплённой версией Xray всё оказалось в порядке. А вот в нативных мостах qWDTT, CSQTT и WDTT Plus нашлась проблема, и её удалось воспроизвести.

Мост связывал UDP-канал с тем, кто первым пришлёт на него датаграмму. Если чужое приложение успевало прислать пакет раньше ядра, канал доставался ему: ответы уходили к нему, а пакеты ядра отбрасывались.
Исправление выглядит так. Клиент (sing-box или Xray внутри Rawline) заранее открывает свой UDP-сокет и сообщает его порт в авторизованном запросе UDP ASSOCIATE. Протокол SOCKS5 это позволяет: в запросе есть поле адреса, которое большинство клиентов просто заполняют нулями. Мост принимает пакеты только с этого порта, а клиент, в свою очередь, проверяет адрес и порт отправителя ответов. Другое приложение не сможет занять порт, который уже занят, поэтому подделать отправителя не выйдет.
Изменение включено только для локальных мостов, удалённые SOCKS-прокси работают по-старому ради совместимости с NAT. На Go написаны регрессии: отказ неизвестному порту, игнорирование чужого первого пакета и успешный UDP-обмен законного клиента после этого. Последний тест я считаю самым важным. Заблокировать UDP целиком легко, гораздо сложнее закрыть дыру и ничего при этом не сломать.
CSQTT: socketpair вместо UDP-порта
CSQTT внутри состоит из двух частей, на Go и на Rust, и между ними ходят сырые IP-пакеты. В оригинале для этого использовался UDP на loopback, то есть ещё одна дверь, в которую может постучаться любое приложение. Пароль здесь прикрутить некуда, поэтому канал заменён на Unix socketpair:
fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_DGRAM|syscall.SOCK_CLOEXEC, 0)
Один конец передаётся Rust-процессу через SCM_RIGHTS по файловому Unix-сокету. Сокет лежит в приватном каталоге с правами 0700, а Rust дополнительно проверяет UID подключившегося процесса. После этого IP-пакеты через сетевой порт не принимаются вообще.
Нейрохрючево?
Вопрос ожидаемый, так что отвечу сразу. Да, с агентами я работал много: Claude Code и Codex. Без них проект такого объёма я бы вёл один заметно дольше.
В основном они читали чужой код (как запускается транспорт, какие сокеты открывает, что делает при ошибке), переносили генератор конфига с C++ на Kotlin и писали тесты. Утечка через первый UDP-пакет тоже сначала всплыла как гипотеза агента при ревью.
Но нейрохрючево получается, когда сгенерированному коду верят на слово. Поэтому правило у меня простое: найденная агентом проблема остаётся гипотезой, пока её не воспроизвёл тест, а исправление не считается исправлением, пока тест не проходит и соседний сценарий не сломан. Что закрывать и какой ценой, решал я сам.
Итог
Самым трудным оказалось не добавить ещё один протокол, а заставить десятки чужих проектов работать как один клиент и при этом не открыть лишних дверей на устройстве. История с SOCKS и tun0 хорошо показывает, что клиент нельзя считать просто оболочкой ядра. Протокол может быть сколь угодно надёжным, а адрес сервера всё равно утечёт через локальный мост, о котором никто не подумал.
Если найдёте дыру, которую я пропустил, пишите issue или в чат.
Попробовать и поучаствовать
Если захотелось поставить, покопаться в коде или просто посмотреть, как всё устроено изнутри:
RawlineMobile: Android-клиент, APK лежат в Releases, приложение само сообщает о новых версиях;
RawlineDesktop: клиент для Windows, Linux и macOS, там же живёт ядро RawlineCore.
Основная жизнь проекта идёт в Telegram-сообществе Rawline. Там выходят беты раньше релизов, разбираются нерабочие профили, обсуждаются новые транспорты, а ещё туда можно написать о баге или предложить идею, если не хочется заводить issue. Если вы автор транспорта и хотите видеть его в клиенте, тоже заходите, обсудим.
А если проект оказался полезен, поставьте звезду на GitHub. Это правда помогает другим его найти.
Комментарии 0