
В предыдущих статьях я разбирал, как мы встроили обход блокировок непосредственно в iOS-приложение: sing-box внутри бинарника, VLESS + Reality, relay в качестве расходного материала, конфиг отдельно от сборки. Та публикация решала ровно одну задачу: довести трафик мессенджера до сервера там, где прямое соединение блокируется.
Но в самом конце был честный абзац, который не давал мне покоя. Я написал тогда: туннель меняет то, как соединение выглядит для цензора по пути, и не меняет того, кто стоит на концах. Оператор relay (в нашем случае мы сами) видит ваш трафик точно так же, как его видел бы провайдер при прямом подключении.
Именно об этой уязвимости пойдет речь. Она называется красиво: метаданные. На практике это означает, что один relay видит больше, чем должен. И мы её закрыли, не затрагивая боевой парк relay вообще. Без маркетинга, по делу.
В чем, собственно, уязвимость
Разберем, что видит один relay в нашей текущей схеме (single-hop, та самая, из прошлой статьи).
Клиент поднимает туннель VLESS + Reality (или Hysteria2) до одного relay. Внутри туннеля идет обращение к острову (так мы называем сервер-бэкенд; островов может быть несколько). Содержимое переписки защищено сквозным шифрованием на уровне приложения (libsignal), это не меняется. Sealed sender скрывает, кто именно отправитель. То есть что написано и кто написал, relay не видит.
Но relay видит другое, и это другое никуда не исчезает:
IP клиента (он же подключается напрямую к relay);
адрес острова назначения (его клиент запрашивает внутри туннеля, и relay этот запрос терминирует, чтобы переслать дальше);
тайминг (когда соединение установлено, сколько висит, когда пошел трафик).
Сложите это вместе. Один relay знает факт: «вот этот IP общается вот с этим островом, вот в это время». Этого достаточно, чтобы привязать человека к сервису. Не к содержанию переписки, а к самому факту использования. Для пользователя под реальной цензурой факт использования бывает важнее содержания.
И вот ключевой момент. Relay — это самая уязвимая точка периметра. Это коробка, стоящая на виду, с известным IP. Её можно изъять. Её оператора можно принудить. И как только это произошло, тот, кто получил relay, получает связку IP — остров — время по всем, кто через него ходил. Single-hop оставляет ровно эту уязвимость: одного скомпрометированного relay достаточно, чтобы слить метаданные.
Это становится особенно острым на следующем шаге нашего роадмапа: мы хотим открыть relay, которые держат волонтеры (мы называем этот трек «гидрой»). Домашний relay волонтера до onion видит IP, остров и тайминг, и при этом его можно изъять, а оператора принудить, всё тем же противником внутри страны. То есть он сольет метаданные ровно по тем людям, которых должен защищать. Поэтому, забегая вперед: сначала onion, потом гидра. К этому вернемся в конце.
Модель: 2-hop onion-lite через вложенный VLESS + Reality
Решение структурное. Не «зашифровать еще сильнее», а сделать так, чтобы ни один relay не видел оба конца одновременно.
Берем не один relay, а два, и строим из них цепочку: клиент ──VLESS/Reality──▶ ENTRY relay ──(непрозрачный туннель)──▶ EXIT relay ──▶ остров
Механика держится на одной возможности sing-box, которая называется detour. Клиент строит два исходящих (outbound), а не один. Outbound на EXIT (VLESS/Reality до relay B) помечен detour: onion-entry. Это значит, что соединение этого outbound до relay B не идет в сеть напрямую, оно само заворачивается внутрь туннеля до relay A.
Адрес острова запрашивается во внутреннем, exit-слое, который умеет расшифровать только relay B.
Что в итоге видит каждый relay:
ENTRY (relay A) видит IP клиента и факт «надо доставить байты до relay B на :443». Он НЕ может прочитать адрес острова: тот лежит внутри Reality-туннеля relay B, а у A нет приватного Reality-ключа relay B. Для A это просто непрозрачный поток на соседний relay.
EXIT (relay B) видит IP relay A и адрес острова. Он НЕ видит IP клиента вообще, для него соединение пришло от entry.
Подчеркну важное: эта слепота криптографическая, а не на честном слове и не на правилах роутинга. ENTRY физически не может прочитать назначение, даже если очень захочет, у него нет ключа. Это не политика «мы обещаем не смотреть», это математика. Ни один relay не сводит источник и назначение вместе.
Почему это вышло почти бесплатно
Тут самое интересное, ради чего, как и в прошлый раз, стоило писать статью. Я ожидал, что onion потребует переделки парка relay. Оказалось, что нет. Совсем нет.
Relay не потребовали ни одной строчки переконфигурации. Наши relay уже используют direct outbound без route-правил (я перепроверил это, читая боевой конфиг). То есть relay просто пересылает байты туда, куда просит VLESS-слой клиента, и ему всё равно куда. Из-за этого:
ENTRY видит обычное VLESS-соединение, которое просит дойти до :443 другого relay. Для него это рядовой клиент, идущий на рядовой адрес.
EXIT видит обычное VLESS-соединение (с IP entry), которое просит дойти до острова. Тоже рядовой клиент.
Цепочка невидима самим relay. Они не знают, что стоят в контуре. Никто из них не в курсе, что является звеном onion-маршрута, для каждого это просто еще одно соединение. Это два очень приятных следствия. Во-первых, ноль переконфигурации парка. Во-вторых, боевой парк вообще не подвергается риску во время раскатки: мы ничего на relay не меняем, вся новая логика живет в клиенте.
flow: xtls-rprx-vision работает поверх detour. Вот этого я честно боялся. Vision сглаживает паттерн «TLS внутри TLS», и был резонный вопрос: а сохранится ли vision на exit-хопе, когда его сокет — это туннель entry, а не настоящая сеть. Если бы не сохранялся, нам пришлось бы держать для relay, выступающих средним или выходным звеном, отдельный inbound-вариант без vision. Это была бы серьезная морока. Проверили на sing-box 1.13: vision поверх detour работает. Exit-хоп держит vision, хотя его сокет — это туннель entry. Никаких vision-less вариантов не нужно. Большое упрощение и большое облегчение.
Доказательство: локальный прототип
Теория хороша, но мы её сначала собрали руками.
sing-box 1.13 на рабочем Mac, конфиг, который цепляет relay-do-fra (entry) → relay-oracle-il (exit, detour) → остров. Никаких изменений на боевом парке, обычный CLI: curl --socks5-hostname 127.0.0.1:1099 https://api.rcq.app/health
→ HTTP 200 за 0.98s против 0.27s при прямом подключении.
Да, лишний хоп стоит латентности. Округленно это 0.7 секунды накладных на хорошей сети. Для мессенджера это нормально: переписка не страдает от +0.7s на установление, а ради разрыва связки IP — остров это приемлемая цена. Главный вывод прототипа был даже не в числах, а в том, что цепочка собирается из обычных relay через detour и доезжает до острова, ничего не ломая по дороге.
Как это выглядит в конфиге
Вот ядро того конфига, что клиент собирает в рантайме, когда onion включен. Я убрал лишнее и оставил суть: entry, один exit, заворот exit через entry и urltest сверху. Ключи и теги реальные.
{ "outbounds": [ { "type": "vless", "tag": "onion-entry", "server": "ENTRY_ADDR", "server_port": 443, "uuid": "...", "flow": "xtls-rprx-vision", "tls": { "enabled": true, "server_name": "www.посторонний-крупный-сайт.com", "utls": { "enabled": true, "fingerprint": "chrome" }, "reality": { "enabled": true, "public_key": "...", "short_id": "..." } } }, { "type": "vless", "tag": "onion-relay-oracle-il", "detour": "onion-entry", "server": "EXIT_ADDR", "server_port": 443, "uuid": "...", "flow": "xtls-rprx-vision", "tls": { "enabled": true, "server_name": "www.другой-крупный-сайт.com", "utls": { "enabled": true, "fingerprint": "chrome" }, "reality": { "enabled": true, "public_key": "...", "short_id": "..." } } }, { "type": "urltest", "tag": "out", "outbounds": ["onion-relay-oracle-il"], "url": "https://api.rcq.app/health", "interval": "5m", "tolerance": 50 } ] }
Несколько деталей, которые тут несут смысл.
detour: onion-entry на exit-outbound — это весь onion в одну строчку. Именно она заворачивает соединение exit внутрь туннеля entry.
utls с отпечатком chrome остается на обоих хопах: ClientHello каждого слоя должен выглядеть как Chrome, иначе vision и Reality на одном из хопов мимо.
Локальный inbound (его тут нет в обрезке) поднимается, как и в single-hop, на 127.0.0.1, порт 1089, тип mixed. Сетевой стек приложения заворачивается на него через тот же connectionProxyDictionary, который мы разбирали в прошлой статье. Со стороны приложения вообще ничего не меняется: оно как ходило в локальный прокси, так и ходит. Двухслойность живет целиком в конфиге sing-box.
urltest сверху — это не косметика, это центральная часть устройства цепочки. Про неё дальше.
Sticky entry guard: урок Tor
Если бы мы просто на каждом запуске выбирали entry случайно или гоняли urltest по всем relay как по entry, мы бы воспроизвели старую ошибку, которую анонимные сети прошли давно. Постоянная ротация входного узла — это плохо. Пассивный наблюдатель, который умеет ронять соединения, может вынудить клиента перебирать входные узлы и тем самым увеличить шанс, что когда-нибудь клиент войдет через узел, который наблюдатель контролирует.
Поэтому входной узел у нас залипающий. Это прямой заём идеи entry guard из Tor.
Логика sticky-входа одинаковая на iOS и Android (отличаются только имена ключей хранилища):
Входной узел выбирается ОДИН раз: самый приоритетный VLESS-relay из пула. Пул отсортирован по приоритету, так что это просто первый элемент.
Выбор персистится на устройстве (iOS: UserDefaults ключ rcq.singbox.onionEntryTag; Android: SharedPreferences ключ onion_entry) и переживает перезапуски приложения.
Между запусками entry НЕ перетасовывается. Если сохраненный тег еще есть в пуле, он переиспользуется. Пассивный противник не может заставить клиента крутить входной узел.
А вот выходной узел крутится свободно. Тот самый urltest гоняет гонку между exit-цепочками (все VLESS-relay, кроме залипшего entry, каждый со своим detour), пробит https://api.rcq.app/health каждые 5m с допуском tolerance: 50. Победивший exit идет в дело. Итог: exit ротируется сам по здоровью маршрута, entry стоит как вкопанный.
Залипший вход — это не «навсегда». Он ротируется ровно в одном случае: вход подтвержденно заблокирован. Под капотом крутится health-loop. На Android это цикл в Session с задержкой 60_000ms: каждую минуту он пробит текущий маршрут, и если проба провалилась, инкрементит счетчик deadStreak. Когда deadStreak >= 2 (две провалившиеся подряд пробы, а не первый же чих сети) И rotateEntry() вернул true, транспорт останавливается, пул relay перечитывается, транспорт стартует заново уже с новым входом, а API- и socket-клиенты пересобираются, чтобы подхватить новый SOCKS-прокси. rotateEntry() round-robin переходит к следующему VLESS-relay по модулю и персистит новый выбор. Для onion-режима нужно минимум два VLESS-relay, иначе ротировать некуда и функция возвращает false.
На iOS петля само-лечения устроена иначе по реализации, но смысл тот же: ретраи раз в ~10 секунд, порог в шесть подряд провалов (те же примерно минуту), после чего вход ротируется и транспорт пересобирается. Одинаков на платформах именно сам guard (выбор и персист входа), а не петля.
Важная подробность: этот health-loop спит, пока onion выключен. На single-hop он не делает ничего. Сторожевой механизм просыпается только в onion-режиме.
Скажу честно про границу проверки. Сам путь rotate-on-block можно прогнать только вживую, на реально заблокированном входе: на эмуляторе мы проверили, что guard персистится и переживает перезапуск, но «вход умер, ротируемся» — это уже когортное тестирование под реальной блокировкой, не лабораторный кейс.
Доставка конфига: флип без релиза в App Store
Здесь работает ровно тот же принцип, что я выводил в прошлой статье: то, что горит или меняется часто, не живет в бинарнике. Только теперь он применяется не к адресу relay, а к самому факту «onion включен».
Конфиг relay у нас подписан Ed25519 и раздается по двум каналам (GitHub-raw как основной плюс Cloudflare KV как вторичный). Клиент его тянет и проверяет подпись против вшитого публичного ключа. Мы добавили в payload необязательный блок: { “onion”: { “enabled”: true } }
Поведение клиентов:
Блок необязателен. Если его нет, onionEnabled = false. Дефолт строго ВЫКЛ. Не «неопределенное состояние», а именно выкл.
Подпись покрывает весь payload, кроме самого поля sig. Канонический JSON у питоновского подписывальщика и у обоих верификаторов (iOS, Android) должен совпадать байт в байт: сортировка ключей, компактные сепараторы, неэкранированные слеши и не-ASCII. Любое расхождение в пробелах или порядке ключей ломает подпись. Плохая подпись не доверяется никогда: клиент откатывается на дисковый кэш, а потом на вшитый статический список