golang

Три маршрута до своего сервера: Xray-core внутри Android-приложения на Kotlin Multiplatform

  • пятница, 13 ноября 2026 г. в 00:00:17
https://habr.com/ru/articles/1107152/

Я делаю 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 не поднялся, пользователь видит ошибку, а не тихо пониженный уровень приватности.

Go внутри Kotlin-процесса

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 и порт 10808

Ядро поднимает 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 проходит цепочку текущего режима так:

  1. Запустить подсистему маршрута. Для прямого маршрута запускать нечего.

  2. Дождаться Ready и порта SOCKS в пределах бюджета на запуск.

  3. Через этот порт сделать GET /health к ретранслятору. Ответ 200 означает, что работает весь путь, а не только локальный порт.

  4. При успехе вернуть маршрут (и запомнить его, если это первый маршрут цепочки, — об этом ниже).

  5. При отказе погасить подсистему, записать причину и перейти к следующему маршруту.

  6. Если цепочка кончилась, бросить 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 и открыть прямое соединение. Если такой проход не удалось остановить, подсистемы не гасятся, а переключение не считается завершённым.

REST как запасной путь доставки

Проход по цепочке отвечает на вопрос, каким маршрутом дойти до ретранслятора. Но бывает, что маршрут найден, рукопожатие 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.