В прошлые разы я разбирал, как мы встроили обход блокировок прямо в 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 не сводит источник и назначение вместе.

Почему это вышло почти бесплатно

Тут самое интересное, ради чего, как и в прошлый раз, стоило писать статью. Я ожидал, что онион потребует переделки парка relay. Оказалось, что нет. Совсем нет.

Relay не потребовали ни одной строчки переконфигурации. Наши relay уже используют direct outbound без route-правил (я перепроверил это, читая боевой конфиг). То есть relay просто пересылает байты туда, куда просит VLESS-слой клиента, и ему всё равно куда. Из-за этого:

  • ENTRY видит обычное VLESS-соединение, которое просит дойти до :443 другого relay. Для него это рядовой клиент, идущий на рядовой адрес.

  • EXIT видит обычное VLESS-соединение (с IP entry), которое просит дойти до острова. Тоже рядовой клиент.

Цепочка невидима самим relay. Они не знают, что стоят в контуре. Никто из них не в курсе, что является звеном онион-маршрута, для каждого это просто ещё одно соединение. Это два очень приятных следствия. Во-первых, ноль переконфигурации парка. Во-вторых, боевой парк вообще не подвергается риску во время раскатки: мы ничего на 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 и доезжает до острова, ничего не ломая по дороге.

Как это выглядит в конфиге

Вот ядро того конфига, что клиент собирает в рантайме, когда онион включён. Я убрал лишнее и оставил суть: 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 это весь онион в одну строчку. Именно она заворачивает соединение 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 по модулю и персистит новый выбор. Для онион-режима нужно минимум два VLESS-relay, иначе ротировать некуда и функция возвращает false.

На iOS петля само-лечения устроена иначе по реализации, но смысл тот же: ретраи раз в ~10 секунд, порог в шесть подряд провалов (те же примерно минуту), после чего вход ротируется и транспорт пересобирается. Одинаков на платформах именно сам guard (выбор и персист входа), а не петля.

Важная подробность: этот health-loop спит, пока онион выключен. На single-hop он не делает ничего. Сторожевой механизм просыпается только в онион-режиме.

Скажу честно про границу проверки. Сам путь rotate-on-block можно прогнать только вживую, на реально заблокированном входе: на эмуляторе мы проверили, что guard персистится и переживает перезапуск, но «вход умер, ротируемся» это уже когортное тестирование под реальной блокировкой, не лабораторный кейс.

Доставка конфига: флип без релиза в App Store

Здесь работает ровно тот же принцип, что я выводил в прошлой статье: то, что горит или меняется часто, не живёт в бинарнике. Только теперь он применяется не к адресу relay, а к самому факту «онион включён».

Конфиг relay у нас подписан Ed25519 и раздаётся по двум каналам (GitHub-raw как основной плюс Cloudflare KV как вторичный). Клиент его тянет и проверяет подпись против вшитого публичного ключа. Мы добавили в payload необязательный блок:

{ “onion”: { “enabled”: true } }

Поведение клиентов:

  • Блок необязателен. Если его нет, onionEnabled = false. Дефолт строго ВЫКЛ. Не «неопределённое состояние», а именно выкл.

  • Подпись покрывает весь payload, кроме самого поля sig. Канонический JSON у питоновского подписывальщика и у обоих верификаторов (iOS, Android) должен совпадать байт в байт: сортировка ключей, компактные сепараторы, неэкранированные слеши и не-ASCII. Любое расхождение в пробелах или порядке ключей ломает подпись. Плохая подпись не доверяется никогда: клиент откатывается на дисковый кэш, а потом на вшитый статический список relay.

Включить онион для когорты это:

# добавить в relays.yaml: onion: { enabled: true }
python tools/sign-relay-config.py tools/relays.yaml > /tmp/relay-config.json
wrangler kv key put --binding=RELAY_CONFIG config --path /tmp/relay-config.json

И всё. Клиенты подхватят новый подписанный конфиг при следующем обновлении и включат онион. Ноль релизов в App Store. Откат такой же мгновенный: пушим конфиг без онион-блока, клиенты на следующем рефреше возвращаются на single-hop. Тот же ключ Ed25519, что и для ротации relay, вшит в оба клиента: подпись неподдельна, флип неподделен.

У этого подхода есть одна тупость: ключ подписи один, поэтому флип бьёт сразу по всем, у кого этот ключ. Для первой когорты это слишком грубо. Поэтому есть второй, более тонкий рычаг: клиентский opt-in. Онион включается, если RelayConfigStore.onionEnabled ИЛИ персональный тумблер устройства (iOS: rcq.singbox.onionOptIn; Android: onion_optin). В настройках это переключатель «Onion routing (experimental)» в разделе Privacy & Network, по умолчанию выключен, доступен только когда обфусцированное соединение уже поднято. Волонтёр-тестер сам включает себя в 2-hop цепочку, не трогая подписанный конфиг вообще. Когорта из одного человека (моё собственное устройство) это теперь просто тумблер.

Честно про границы

Туннель не магия, и вложенный туннель тоже не магия. Перечислю границы прямо, без смягчений, потому что продавать онион как анонимность было бы нечестно.

Это ещё НЕ «лёгкий клиент». Пограничный хоп, тот, что пересекает границу, это по-прежнему клиент → ENTRY, а ENTRY это иностранный relay. Значит, этот хоп обязан оставаться тяжёлым (Reality / Hy2), это не лёгкая безобидная HTTPS-струйка. Видение «лёгкий клиент, тяжёлый relay → остров» требует ДОМАШНИХ входных relay (клиент → домашний это внутрибордерный лёгкий хоп; домашний → иностранный это тяжёлый хоп). Это трек гидры, и онион его как раз открывает, но сам онион лёгким клиентом нас ещё не делает.

Оба relay сегодня наши. Это значит, что МЫ как оператор по-прежнему видим оба конца, просто разнесённые по двум коробкам. Ни один отдельный relay не видит оба конца, но RCQ как оператор видит. Скажу прямо: онион рвёт связку для сетевых наблюдателей, не для нас самих. Реальный противник, от которого онион защищает СЕЙЧАС, это ОДИН изъятый или принуждённый relay, в том числе будущий волонтёрский relay гидры. От оператора, который контролирует оба relay и остров, онион внутри одной инфраструктуры не спасает. Это структурная, а не криптографическая граница, и её снимет только разнесение relay по разным независимым операторам.

Глобальный пассивный противник вне зоны действия. Тот, кто наблюдает большую часть сети одновременно и коррелирует тайминги, деанонимизирует и через нашу цепочку. Это не Tor, и я не буду называть это Tor-ом. Корректная формулировка: это «Tor-lite поверх нашего собственного пула». Полную несвязываемость против глобального пассивного наблюдателя онион не даёт, и от Tor её тоже не получить. Мы этого и не обещаем.

IP горят так же быстро. Лишний хоп не отменяет того, что оба relay так же обнаружимы блок-листами и DPI. Онион это не волшебное решение проблемы ротации адресов, это структурное изменение, которое разрывает связку метаданных, но добавляет латентность и удваивает число relay на пути. Связка между relay и островом сегодня это обычный TLS/HTTP, не зашифрованный под сквозным ключом приложения. Exit-relay видит plaintext маршрутные метаданные (получатель, тип, размер, время), хотя содержимое прочитать не может. Онион прячет связку IP - использование от сетевых наблюдателей, но не сам факт использования от relay.

Дефолт ВЫКЛ, раскатка поэтапная. Single-hop это безопасный пол. Каждый онион-клиент откатывается на single-hop, если онион выключен в конфиге ИЛИ если в пуле меньше двух VLESS-relay. Связность от онион никогда не становится хуже: в худшем случае это ровно тот single-hop, что был в прошлой статье. Сам флип когортный и под наблюдением: люди, которых это защищает, под реальной цензурой, и сломать им обход неаккуратной флит-вайд раскаткой недопустимо.

Что это открывает: почему сначала онион, потом гидра

Теперь к стратегии, ради которой всё затевалось.

Следующий большой шаг это relay, которые держат волонтёры внутри страны (гидра). Домашние входные relay это и есть путь к лёгкому клиенту: внутрибордерный хоп может быть лёгким и безобидным, а тяжёлый Reality-хоп уезжает на участок домашний → иностранный, который домашнему провайдеру виден иначе.

Но открыть волонтёрские relay ДО онион было бы вредительством. Домашний relay волонтёра в single-hop видит IP клиента, остров и тайминг вместе. И этот же relay можно изъять, а его оператора принудить, всё тем же противником внутри страны. То есть мы бы своими руками расставили по стране коробки, каждая из которых сдаёт связку IP - остров по тем самым людям, которых должна защищать, плюс подставили бы волонтёра-оператора под юридический риск.

Онион это снимает. После онион даже изъятый домашний relay не сводит источник и назначение: как entry он не видит остров (нет ключа exit), как exit он не видит IP клиента. Связка криптографически разорвана. Только теперь можно открывать пул для чужих рук.

Поэтому порядок жёсткий: онион → broker (Lox-подобная раздача relay) → гидра. Никакой гидры до онион.

Что стоит забрать из этого текста

Если вы строите похожий транспорт, три вещи, которые стоит держать в голове.

Метаданные это отдельный слой угрозы, не тот же, что обход. Прошлая статья закрывала «дойти до сервера». Эта закрывает «чтобы один relay по дороге не связал ваш IP с тем, куда вы ходите». Это разные задачи, и решать их надо разными механизмами. Не путайте обход блокировок с приватностью метаданных, как не надо путать обход с приватностью переписки.

Иногда нужная фича уже лежит в вашем стеке. 2-hop онион у нас это detour в sing-box плюс залипший вход плюс urltest по выходам. Мы не писали свою анонимную сеть. Самое дорогое открытие было приятным: relay не потребовали переконфигурации, а vision пережил detour, так что боевой парк во время раскатки вообще не трогался.

Залипайте на входе, ротируйте на выходе. Это урок Tor, и он применим к любой цепочке: постоянная ротация входного узла играет на руку наблюдателю, который умеет ронять соединения. Вход держите как guard, персистите его, ротируйте только на подтверждённой блокировке. Выход крутите свободно по здоровью.

И честно про границы, всегда. Онион внутри одной инфраструктуры защищает от ОДНОГО скомпрометированного relay, а не от оператора этой инфраструктуры и не от глобального пассивного наблюдателя. Если вы скажете пользователю больше, чем механизм даёт, вы соврёте человеку, который полагается на вас под реальной цензурой.


Всё описанное живёт в нашем мессенджере RCQ. Онион-стек собран на обеих платформах и лежит DEFAULT-OFF: single-hop это пол, на который всё откатывается. Клиент под iOS у нас с открытым исходным кодом, так что вместо «поверьте на слово» можно посмотреть, как именно собирается 2-outbound цепочка, как устроен sticky guard и как читается онион-флаг из подписанного конфига: github.com/rcq-messenger/rcq-ios.

Если занимаетесь похожими цепочками и набили свои шишки, особенно интересно про практику ротации входных узлов под реальной блокировкой и про латентность многохоповых цепочек на мобильных сетях. Расскажите в комментариях.