Мы делаем мессенджер. Весной 2026 наш бэкенд начал отваливаться у части пользователей из России: HTTPS-запросы к API таймаутятся, WebSocket не поднимается. Картина знакомая всем, кто держит сервис с одним доменом и одним IP.
Для мессенджера это приговор. Не «неудобно», а именно приговор: приложение, которое не может даже подключиться, бесполезно. И вариант «попросите пользователя сначала включить VPN» нас не устраивал совсем. Ниже разберу, почему мы в итоге встроили обход прямо в приложение, на чём он работает и на какие грабли мы наступили. Без маркетинга, по делу.
Почему не «просто VPN»
Первая мысль у всех одинаковая: пусть юзер поставит VPN. Но если посмотреть на это глазами продукта, всё разваливается.
Во-первых, это убивает воронку. Каждый дополнительный шаг до «приложение заработало» стоит вам части аудитории, а «установите и настройте отдельное приложение» это не шаг, это пропасть. Особенно болезненно, что отваливается ровно та часть пользователей, которой продукт нужнее всего.
Во-вторых, сам VPN это движущаяся мишень. Популярные протоколы (OpenVPN, WireGuard, IKEv2) давно и неплохо детектируются по сигнатурам, диапазоны известных VPN-провайдеров режут пачками. То есть вы делегируете критичную для вас функцию стороннему приложению, которое завтра само перестанет работать.
Нам хотелось другого поведения: пользователь открывает мессенджер, и он работает. Без отдельного приложения, без подписки на чужой сервис, без VPN-профиля в системных настройках iOS. Обход должен быть деталью реализации, а не задачей пользователя.
Что именно блокируется
Чтобы понимать, что чинить, надо понимать, что ломается. У нас довольно типичная схема: HTTPS API и WebSocket на одном домене. Заблокировать такое соединение можно несколькими способами, и обычно их комбинируют:
по IP-адресу сервера;
по имени домена в ClientHello (поле SNI отправляется открытым текстом даже в TLS);
по DPI, который смотрит на саму структуру трафика и опознаёт протокол.
Обычное TLS-соединение на ваш домен срезается по SNI на раз. Значит, задача формулируется так: соединение должно выглядеть как обращение к какому-то другому, ничем не примечательному и заведомо разрешённому хосту. Не «зашифровать сильнее», а именно «выглядеть иначе».
Почему VLESS и Reality
Немного истории, она тут важна. Shadowsocks и VMess в своё время решали ровно эту задачу, и какое-то время решали хорошо. Проблема в активном пробинге: цензор не просто смотрит на ваш трафик пассивно, он сам отправляет на подозрительный сервер пробный запрос. Если сервер отвечает как прокси (а ранние реализации отвечали узнаваемо), адрес уезжает в блок. Плюс накопились пассивные сигнатуры, по которым «прокси-трафик» отличался от обычного HTTPS.
Reality решает это аккуратным трюком. Прокси-сервер не предъявляет собственный TLS-сертификат. Вместо этого во время рукопожатия он проксирует TLS-handshake на реальный, посторонний, популярный сайт. Для пассивного наблюдателя и для активного пробера ваш сервер неотличим от этого сайта: валидный сертификат, валидное рукопожатие, валидная цепочка. И только клиент, у которого есть правильный публичный ключ, после рукопожатия «переключается» на настоящий туннель. Постороннему пробингу переключиться не на что, он видит обычный крупный сайт.
Что это даёт на практике:
нет своего домена, который можно занести в блок по SNI;
нет своего сертификата, по которому можно опознать «вот это прокси»;
активный пробинг упирается в чужой легитимный сайт.
VLESS тут это лёгкий транспорт без собственного слоя шифрования (шифрование берёт на себя TLS), с маленьким и невыразительным отпечатком. А поток xtls-rprx-vision дополнительно сглаживает паттерн «TLS внутри TLS», который иначе виден по характерным размерам пакетов.
Ничего из этого мы не изобретали. Reality и sing-box это чужая, и очень хорошая, работа. Интересная часть начиналась дальше: как затащить это внутрь обычного приложения так, чтобы пользователь вообще ничего не заметил.
sing-box внутри приложения
sing-box это универсальная прокси-платформа на Go, и среди прочего она умеет быть клиентом VLESS + Reality. Обычно её запускают как отдельный процесс с конфигом. Нам отдельный процесс не нужен, нам нужно, чтобы это жило внутри iOS-приложения.
Здесь помогает gomobile. Через gomobile bind Go-код собирается в нативный .xcframework, который линкуется в приложение как обычная зависимость. То есть sing-box у нас не CLI рядом, а фреймворк внутри бинарника. Запускаем его прямо в процессе приложения.
Поднятый таким образом sing-box открывает локальный inbound (тип mixed, то есть SOCKS и HTTP сразу) на 127.0.0.1 на случайном порту. Дальше есть развилка, и она принципиальная.
Можно сделать системный VPN через NEPacketTunnelProvider: тогда через туннель пойдёт весь трафик устройства. Можно сделать прокси уровня приложения: тогда через туннель пойдёт только трафик вашего приложения. Мы выбрали второе, и вот почему. Нам не нужно гнать через relay весь телефон, нам нужно довести до сервера только трафик мессенджера. А раз так, то Network Extension с его архитектурой, отдельным процессом-расширением, лимитами по памяти и дополнительными вопросами на ревью в App Store нам просто не нужен. Меньше движущихся частей, меньше точек отказа.
На практике это выглядит так: sing-box крутит локальный прокси, а сетевой стек приложения (URLSession) мы заворачиваем на этот локальный адрес.
let config = URLSessionConfiguration.default config.connectionProxyDictionary = [ "SOCKSEnable": 1, "SOCKSProxy": "127.0.0.1", "SOCKSPort": localPort, ]
Весь остальной телефон при этом не затронут, никакого VPN-профиля в настройках не появляется. Минус подхода честный: это не универсальный VPN, через него не пойдут другие приложения. Но нам это и не требовалось.
Конфиг для sing-box генерируем в рантайме. Если убрать лишнее, ядро выглядит так:
{ "inbounds": [{ "type": "mixed", "listen": "127.0.0.1", "listen_port": 0 }], "outbounds": [{ "type": "vless", "server": "RELAY_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": "..." } } }] }
Отдельно отмечу utls с отпечатком chrome. Стандартная Go-реализация TLS имеет свой узнаваемый ClientHello, и это само по себе сигнатура. uTLS подменяет ClientHello так, чтобы он совпадал с настоящим Chrome. Деталь мелкая, но из таких мелочей и складывается «выглядит как обычный браузер».
Грабли с relay
Теперь то, ради чего стоило писать эту статью. С протоколом всё оказалось ровно. По граблям мы прошлись на инфраструктуре.
Первый relay мы подняли на дешёвом VPS у типичного хостера. Адрес сгорел быстро. Диапазоны датацентров и хостингов, на которых исторически много прокси, отслеживаются и режутся либо превентивно, либо очень оперативно. Reality прекрасно прячет протокол, но он никак не прячет сам факт «трафик идёт на IP из подозрительного диапазона».
Мы перенесли relay на адрес в диапазоне крупного облачного провайдера. И вот он живёт. Логика простая: вырезать оптом адресное пространство большого облака дорого по сопутствующему ущербу, на тех же адресах висит масса легитимных сервисов, которые ломать никто не хочет.
Вывод, который мы для себя записали: с Reality слабое место это не протокол, а IP. Горит именно адрес. Значит, relay по определению расходник, и относиться к нему надо как к расходнику.
Адрес relay не должен быть зашит в бинарник
Прямое следствие предыдущего пункта. Если адрес relay захардкожен в приложении, то сгоревший IP означает новый релиз в App Store и ожидание ревью. Для расходника, который может сгореть в любой день, это неприемлемо.
Поэтому параметры relay (адрес, ключи, SNI) приложение должно получать отдельно от своей сборки. Тогда смена relay это смена конфига, а не релизный цикл. Конкретный механизм доставки конфига можно выбрать разный, важен сам принцип: то, что горит часто, не живёт в бинарнике.
Честно про границы
Туннель это не магия, и продавать его как магию нечестно.
Оператор relay (в нашем случае мы сами) видит ваш трафик к нашим серверам ровно так же, как его видел бы ваш провайдер при прямом подключении. Туннель меняет то, как соединение выглядит для цензора по дороге, и не меняет того, кто стоит на концах. Содержимое переписки у нас в любом случае защищено сквозным шифрованием на уровне приложения (libsignal), и туннель к этому ничего не добавляет и ничего не отнимает. Это важно разделять: обход блокировок и приватность переписки это две разные задачи, которые решаются разными слоями.
Reality хорошо держит активный пробинг, но «навсегда» в этой области не существует. IP горят, сигнатуры накапливаются, методы детекта развиваются. Это гонка, и относиться к ней надо как к гонке: иметь запас адресов, уметь их менять быстро, мониторить, что именно отвалилось.
У нас обход по умолчанию выключен. Приложение сначала пробует подключиться напрямую, и только если прямое соединение не проходит, поднимает туннель. Никакого смысла гонять трафик через relay там, где сеть и так открыта, нет.
Что стоит забрать из этого текста
Если вы решаете похожую задачу, три вещи, которые сэкономят вам время:
Прокси уровня приложения часто лучше системного VPN. Если вам надо довести до сервера только свой трафик, NEPacketTunnelProvider это лишняя сложность. Локальный inbound плюс connectionProxyDictionary закрывают задачу меньшими силами.
Протокол это лёгкая часть. VLESS + Reality + sing-box это решённая задача, всё уже написано до вас. Тяжёлая часть это инфраструктура: какие адреса вы используете, как быстро вы их меняете, как вы это всё мониторите.
Планируйте ротацию с первого дня. Не «когда-нибудь вынесем relay в конфиг», а сразу. Потому что первый же сгоревший IP покажет вам, есть у вас ротация или нет, и узнавать это в проде неприятно.
Всё описанное живёт в нашем мессенджере RCQ, сейчас он в открытой бете на iOS. Клиент под iOS у нас с открытым исходным кодом, так что при желании можно посмотреть, как именно устроена работа с транспортом, а не верить на слово:
Если занимаетесь чем-то похожим и набили свои шишки на инфраструктуре, расскажите в комментариях, особенно интересно про практику ротации адресов.
