Три маршрута до своего сервера: Xray-core внутри Android-приложения на Kotlin Multiplatform
- пятница, 13 ноября 2026 г. в 00:00:17
Я делаю SHIFROM — мессенджер с открытым кодом и сквозным шифрованием. Клиент написан на Kotlin Multiplatform, сообщения идут через сервер-ретранслятор. Эта статья не про криптографию, а про задачу попроще на вид: как одно приложение держит связь со своим сервером, если маршрутов несколько и любой из них может перестать работать.
Сначала о статусе. Проект в стадии Alpha 2, поддерживаемого публичного релиза нет; на GitHub лежит только историческая тестовая APK от Alpha 1. Протокольная композиция (X3DH, Double Ratchet и Sealed Sender поверх libsodium) не проходила независимый аудит. Первым идёт Android; версии для iOS и веба я планирую, но сроков не называю. Всё сказанное о поведении в реальной сети опирается на мои собственные прогоны, и в конце я расскажу, как их записываю.
У клиента один транспортный интерфейс и три способа дойти до ретранслятора:
прямой WebSocket поверх TLS — самый дешёвый по задержке и батарее;
WebSocket через встроенный Xray-core;
WebSocket через onion-сервис Tor — самый медленный, только для текста.
Xray и Tor здесь — резервные маршруты для сетей, где соединение нестабильно или обрывается. Есть ещё доставка через REST, но это не отдельный маршрут, о ней ниже.
Маршрут пользователь не выбирает. Он выбирает режим приватности, а режим задаёт упорядоченную цепочку:
Режим | Цепочка | Если отказало всё |
|---|---|---|
Standard (по умолчанию) | прямой → Xray → Tor | ошибка в уведомлении |
Private | Xray → Tor | ошибка в уведомлении |
Ghost | только Tor | ошибка, без перехода на менее приватный маршрут |
Ghost не откатывается никогда: если Tor не поднялся, пользователь видит ошибку, а не тихо пониженный уровень приватности.
Xray-core написан на Go. Его можно запускать отдельным исполняемым файлом в дочернем процессе или загрузить библиотекой в свой процесс. Я выбрал второе: libXray — обёртка от разработчиков Xray, которую gomobile собирает в JNI-библиотеку libgojni.so с тонким Java-слоем. Ни дочернего процесса, ни межпроцессного обмена.
Причин три.
Один жизненный цикл. Дочерний процесс — второй объект, за жизнью которого надо следить: запустить, заметить, что он упал или был выгружен системой, перезапустить и согласовать с состоянием приложения. Внутри процесса ядро запускается и останавливается вместе с приложением, и согласовывать нечего.
Foreground-сервис. У мессенджера уже есть foreground-сервис, который держит процесс живым. Xray живёт внутри него, второе постоянное уведомление не нужно.
Никаких исполняемых файлов. С Android 10 приложение не может запускать файлы из своего домашнего каталога, так что отдельный бинарник пришлось бы упаковывать под видом .so и запускать из nativeLibraryDir. Библиотеку же загружает обычный System.loadLibrary, без дополнительных ухищрений.
Цена — размер. Одна libgojni.so весит около 45 МБ, а ABI четыре: arm64-v8a, armeabi-v7a, x86 и x86_64. В репозитории они лежат распакованными, jar отдельно и .so отдельно, а не одним .aar. Android Gradle Plugin не даёт вложить локальный .aar в другой AAR (проверка hasLocalAarDeps), а модуль :shared:core:xray сам собирается в AAR.
Со стороны Kotlin Multiplatform это обычный expect/actual. В commonMain лежат интерфейс XrayService и состояние, androidMain оборачивает libXray, а jvmMain — заглушка, которая навсегда остаётся в Off, чтобы десктопная сборка компилировалась без нативной библиотеки. Для iOS нужна отдельная сборка gomobile, которая работает только на macOS. Её пока нет.
Ядро поднимает SOCKS5-слушатель на 127.0.0.1, и сетевой клиент приложения (OkHttp под Ktor) ходит через него как через обычный прокси. Пароля у слушателя нет: он доступен только на петлевом интерфейсе.
Первая версия слушала порт 10808, привычный для Xray-клиентов. На одном из первых прогонов на двух устройствах каждая свежая установка падала с bind: address already in use: этот порт уже держало другое приложение с собственным SOCKS-слушателем.
Теперь порт выбирает ОС. Я привязываю ServerSocket к 127.0.0.1 с портом 0, читаю назначенный номер, закрываю сокет и отдаю номер в конфиг Xray. Между закрытием и повторной привязкой есть короткое окно для гонки. В комментарии к функции так и написано, и на этот случай достаточно выбрать порт заново. Дальше номер едет через состояние Ready, а не через константу.
Вторая ловушка: libXray может вернуть успех, хотя слушатель не поднялся. Поэтому после запуска есть перекрёстная проверка:
if (!LibXray.getXrayState()) { error("runXrayFromJSON returned success but getXrayState() is false (port collision?)") }
shared/core/xray/src/androidMain/kotlin/phantom/core/xray/XrayServiceFactory.android.kt
У ядра четыре состояния:
sealed class XrayState { data object Off : XrayState() data object Starting : XrayState() data class Ready(val socksPort: Int) : XrayState() data class Failed(val message: String) : XrayState() }
shared/core/xray/src/commonMain/kotlin/phantom/core/xray/XrayState.kt, комментарии убраны
Наружу состояние отдаётся как StateFlow. start() и stop() идемпотентны и выполняются под мьютексом: libXray не рассчитан на параллельные запуск и остановку, а повторный старт работающего ядра вернул бы ошибку и сломал состояние. Ошибка запуска не бросается исключением, а превращается в Failed. Процентов прогресса, как у Tor, нет: runXrayFromJSON синхронный и возвращается, когда слушатели подняты или запуск не удался.
Холодный и тёплый старт отличаются сильно. Первый запуск, то есть загрузка библиотеки, инициализация, DNS и первое рукопожатие, на моём тестовом телефоне занимал до 30 секунд. Тёплый перезапуск укладывается в доли секунды. Это наблюдения из разработки, а не замеры из таблицы полевых тестов.
Эти 30 секунд чуть не стоили мне маршрута. Черновик менеджера маршрутов давал один бюджет в 5 секунд на запуск и проверку вместе, и на первом запуске цепочка обрывалась бы раньше, чем ядро проснётся. Ошибка всплыла на ревью до слияния, и я разделил бюджет на два.
TransportManager проходит цепочку текущего режима так:
Запустить подсистему маршрута. Для прямого маршрута запускать нечего.
Дождаться Ready и порта SOCKS в пределах бюджета на запуск.
Через этот порт сделать GET /health к ретранслятору. Ответ 200 означает, что работает весь путь, а не только локальный порт.
При успехе вернуть маршрут (и запомнить его, если это первый маршрут цепочки, — об этом ниже).
При отказе погасить подсистему, записать причину и перейти к следующему маршруту.
Если цепочка кончилась, бросить NoTransportReachableException со списком попыток.
Для каждой проверки создаётся свой OkHttp-клиент на один запрос, чтобы проверка не делила соединения с настоящим WebSocket. /health отвечает без авторизации, поэтому проверка не зависит от пользователя и её безопасно повторять.
Бюджеты сейчас такие:
Маршрут | Запуск | Таймаут HTTP-вызова | Внешний предел проверки |
|---|---|---|---|
Прямой | — | 10 с, две попытки с паузой 400 мс | 25 с |
Xray | 30 с | 20 с | 30 с |
Tor | минуты | 60 с | 90 с |
Это бюджеты отдельных этапов, а не общий потолок. Для Xray 30 секунд отсчитываются на ожидание Ready после вызова запуска, сам запуск идёт до них. Остановка подсистемы при переходе к следующему маршруту тоже идёт сверх бюджетов.
На этой таблице я дважды споткнулся о вложенные таймауты. Сначала я поднял таймаут вызова для Xray до 20 секунд, а внешний withTimeout оставался 10-секундным, и прибавка ничего не дала: проверку раньше отменял внешний предел. Потом оказалось, что у OkHttp кроме callTimeout есть таймауты на соединение, чтение и запись, по умолчанию по 10 секунд. Проверка падала через 10,07 с с SocketTimeoutException, хотя в логе стояло 20 000 мс. Теперь все четыре таймаута OkHttp равны callTimeout, внешний предел оставляет запас поверх внутреннего (у Xray 30 секунд против 20, у Tor 90 против 60, у прямого 25 против двух попыток по 10), и правило «внешний не меньше внутреннего» записано в коде.
Урок про документацию: ADR-020 долго обещал, что проверка укладывается в 5 секунд. В коде константа PROBE_TIMEOUT_MS = 5000 осталась только для тестов, а рабочий путь давно жил по таблице выше. Когда код и документ расходятся, я верю коду и правлю документ, — для ADR-020 это сделано датированной поправкой.
Ещё одно правило касается отмены. Истёкший бюджет — обычный отказ маршрута, цепочка идёт дальше. Отмена корутины снаружи, например при смене режима, означает «прекратить проход целиком». TimeoutCancellationException в Kotlin наследует CancellationException, поэтому catch для таймаута обязан стоять выше catch для отмены, иначе таймаут одного маршрута обрывал бы всю цепочку.
После успеха менеджер запоминает, какой маршрут сработал и когда. Подсказка действует 24 часа с момента записи.
Первая версия запоминала любой успешный маршрут и ставила его первым. Получилась ловушка. На Wi-Fi случается короткий сбой, прямой путь и Xray не проходят, срабатывает Tor, и подсказка становится Tor. Сеть восстанавливается, но при следующем запуске первым пробуется Tor, он проходит, а прямой путь даже не проверяется. И так по кругу: каждое подключение через Tor заново ставило отметку времени, поэтому 24 часа отсчитывались от последнего успеха, и ловушка могла держаться дольше суток. Я поймал это в тесте: телефон на Wi-Fi, где накануне всё работало напрямую, сразу ушёл в Tor.
Теперь подсказка сохраняется, только если сработал первый маршрут цепочки:
private fun onSuccess(kind: TransportKind, strategy: TransportStrategy) { val primary = strategy.chain.first() if (kind == primary) { preferences.lastWorkingTransport = kind preferences.lastSuccessAt = nowMs() } else { preferences.lastWorkingTransport = null preferences.lastSuccessAt = null } preferences.transportFailureCount = 0 }
shared/core/transport/src/commonMain/kotlin/phantom/core/transport/TransportManager.kt, логирование убрано
В итоге порядок цепочки тот, что задан режимом (с одним исключением для VPN, о нём ниже), а резервный маршрут основным не становится. При значимой смене сети подсказка тоже сбрасывается. ADR-020 описывал подсказку как перестановку цепочки — это второе место, где документ отставал от кода, и та же поправка его исправила.
Проход запускается при холодном старте приложения, перезапуске foreground-сервиса, смене режима и значимой смене сети. Значимой считается такая смена: сеть пропала или появилась, Wi-Fi сменился мобильной сетью или наоборот, появился или исчез VPN, изменился флаг NET_CAPABILITY_VALIDATED. Между двумя проходами должно пройти не меньше 5 секунд. Исключение — сеть вернулась после полного отсутствия: тогда проход идёт сразу, иначе приложение застряло бы на устаревших решениях.
Если на устройстве включён системный VPN, маршрут Xray из цепочки убирается. В таком сочетании проверка у меня не проходила ни разу и только добавляла ожидание до таймаута. Трафик приложения в этом случае и так идёт через VPN. Поэтому под VPN режим Private начинает сразу с Tor. Подсказка при этом сравнивается с первым маршрутом режима, а не с первым фактически проверенным, так что успех Tor здесь не запоминается.
Смена режима — самое тонкое место. Переключение идёт одной неотменяемой транзакцией (NonCancellable): перекрыть REST-запросы, закрыть WebSocket, остановить проход цепочки, если он ещё идёт, и только потом погасить Xray и Tor. Два правила появились после найденных ошибок.
Очередь неотправленного не досылается старым маршрутом. При выходе из аккаунта досылка правильна, а при смене режима нет: пользователь только что попросил этот маршрут не использовать. Сообщения остаются в очереди и уходят новым маршрутом.
Каждый проход помнит эпоху режима, в которой начался, и публикует результат, только если эпоха не изменилась. Сверка и публикация — одна критическая секция. Иначе проход, застрявший в нативном вызове, мог бы проснуться после переключения на Ghost и открыть прямое соединение. Если такой проход не удалось остановить, подсистемы не гасятся, а переключение не считается завершённым.
Проход по цепочке отвечает на вопрос, каким маршрутом дойти до ретранслятора. Но бывает, что маршрут найден, рукопожатие WebSocket прошло, а фреймы потом теряются. Для этого есть REST поверх того же маршрута: отправка запросом и короткий опрос входящих. Криптография сообщений при переходе между WebSocket и REST не меняется.
Режимом управляет отдельный конечный автомат с тремя состояниями:
WsActive — всё идёт через WebSocket, опрос выключен;
RestActive — отправка и опрос через REST;
WsCandidate — сокет есть, но ещё не доказал, что работает, и опрос остаётся включённым для страховки.
Автомат стартует в RestActive и переходит в WsActive только после доказательства на этой самой сессии: подтверждения исходящего сообщения или входящего фрейма. Отправка через REST повторяется до пяти раз с паузами 1, 3, 8, 20 и 60 секунд и одним и тем же Idempotency-Key, так что сервер отбрасывает дубли.
Деградацию WebSocket после подключения сейчас обрабатывает этот слой, а не повторный проход цепочки. Детектор, который мог бы запускать проход, пока работает только как телеметрия: пишет в лог, когда сработал бы, и ничего не делает. Причина — шум: на одной мобильной сети фон составил два таймаута пинга за семь минут, и порог-кандидат «два события за пять минут» срабатывал бы на обычном поведении сети.
Состояние видно в постоянном уведомлении foreground-сервиса. Тексты из русской локализации:
Состояние | Уведомление |
|---|---|
идёт проход | Подключение через {маршрут}… · {режим} |
подключено | Подключено через {маршрут} · {режим} |
работает REST | Подключено через {маршрут} · Ограниченный режим · {режим} |
WebSocket на проверке | Подключено через {маршрут} · Проверка соединения · {режим} |
отказало всё | Нет связи с ретранслятором (попыток: N) · {режим} |
Для Tor уведомление показывает ещё стадию и процент запуска, а при его отказе — отдельный текст. Повторить попытку можно из настроек или перезапуском приложения.
Xray-core и его TLS-модуль распространяются под MPL-2.0, libXray — под MIT, SHIFROM — под AGPL-3.0-or-later. MPL — файловый копилефт: MPL-файлы остаются под MPL, а работа, в которую они входят, может распространяться под другой лицензией, если получатель может взять исходники MPL-части.
В полном тексте MPL-2.0 есть приложение Exhibit B — пометка «несовместимо со вторичными лицензиями». Сначала я решил, что раз она есть в файле LICENSE, то с GPL-семейством Xray не сочетается. Это не так: пометка действует, только если автор прикрепил её к своим файлам, а в той версии Xray-core, что собрана в приложение, её нет ни в одном файле. Об этом прямо говорит FAQ Mozilla по MPL 2.0 (вопрос 25), а AGPL-3.0 входит в список вторичных лицензий самой MPL.
Кроме Xray, в библиотеку вкомпилировано ещё три десятка Go-модулей: MIT, BSD, Apache-2.0, два модуля под GPL-3.0-or-later и один под LGPL-3.0 с исключением для линковки. Точные версии я взял из самого бинарника — Go записывает их туда при сборке, и go version -m libgojni.so их показывает. Тексты лицензий всех модулей лежат в репозитории, в third_party/libxray/.
Схема у меня консервативная: исходники Xray я не меняю, библиотека собирается из неизменённого upstream. Это моё понимание, а не юридическое заключение: внешнюю проверку лицензий я наметил до Beta.
JNI-библиотеку собирает отдельный workflow в GitHub Actions, который запускается вручную: тулчейн большой, а libXray обновляется редко.
Клонирует XTLS/libXray на заданной ссылке (ветка, тег или SHA) и печатает хеш коммита.
Берёт версию Go из go.mod самого libXray.
Ставит NDK r27c: libXray передаёт линкеру max-page-size=16384, а это требует NDK r26 или новее и даёт библиотеки под 16-КБ страницы, которые проходят проверку Play Console для Android 15+.
Падает, если в .aar нет .so хотя бы для одного из четырёх ABI: частичный артефакт дал бы UnsatisfiedLinkError на устройстве.
Выкладывает .aar артефактом, а в репозиторий я распаковываю его отдельным коммитом со ссылкой на исходный SHA.
В репозитории лежит публичная таблица полевых тестов. Правило простое: если утверждение не подтверждено строкой таблицы, его не должно быть ни на сайте, ни в README. Одна строка — один прогон. В колонках дата, коммит, устройство, версия Android, сеть, тип доступа, состояние VPN, маршрут, тест, результат, задержка и известное ограничение.
Если значение не записали в момент теста, в ячейке стоит not recorded, а не реконструкция: правдоподобная догадка в таблице доказательств хуже честной дыры. Провалы записываются наравне с успехами. Например, там есть прогон, где после успешного рукопожатия WebSocket исходящие фреймы терялись.
Все прогоны делал я сам: бюджетный Android-телефон в мобильной сети, иногда в паре с эмулятором. Это не независимый бенчмарк. Один прогон говорит о том, что случилось в тот раз, и не больше.
Про маршрут Xray в таблице ровно одна строка: приёмочный прогон, маршрут доступен от начала до конца, PASS, задержка не записана, повтора нет. В ADR-019 этот прогон сначала назывался «production-validated». Позже я поправил формулировку прямо в документе: это был приёмочный прогон на одном устройстве, в одной сети и в один день, а не независимая и не всеобщая проверка. Решение осталось прежним, исправлена только метка.
Чего не хватает, таблица перечисляет сама:
одна модель телефона;
версия Android не записана нигде, и это провал процесса, а не пропуск в таблице;
задержка почти не измерялась, распределения задержки доставки нет;
у маршрута Xray один прогон без повтора, а WebSocket в реальном времени внутри него спроектирован, но в поле не проверен;
ни одну строку не воспроизвёл независимый человек.
Есть и план из восьми сценариев для режимов приватности: смена сети посреди разговора, смена режима при подключении, Ghost без Tor, отказ всех маршрутов и другие. Прогонов по нему в таблице нет, поэтому я на него не ссылаюсь.
Повторные прогоны маршрута Xray, другие телефоны и версии Android, причём с записанной версией, замеры задержки, сборка ядра под iOS. Дат у этого нет.
Код, ADR и таблица полевых тестов — на github.com/LiudvigVladislav/SHIFROM, о проекте — на shifrom.com.