Мы делаем RCQ, мессенджер со сквозным шифрованием и открытым кодом (Android, iOS, протокол). В нашем основном регионе приложение блокируют, поэтому обход встроен внутрь клиента: sing-box в самом приложении, пул релеев, подписанный конфиг с их списком, запасной путь через CDN.
Три слова, без которых дальше не обойтись. Остров это сервер, на котором живут аккаунты: наш основной отвечает на api.rcq.app, а поднять свой может любой желающий, код открыт. Релей это промежуточная машина с sing-box, через которую клиент доходит до острова, когда напрямую не пускают; их у нас полтора десятка, и все вместе они флот. Фронт это то же самое имя, спрятанное за Cloudflare, путь на случай, когда заблокирован сам остров.
Всё это писалось месяцами и ни разу не измерялось. Мы знали, что механизмы есть, а срабатывают ли они хоть когда-нибудь и в каком проценте случаев, никто из нас сказать не мог, потому что спрашивать было не у чего.
В начале августа ушло двое суток на приборы. Ни одной новой функции, только измерение уже написанного. Нашлось восемь мест одной и той же формы: механизм построен, задеплоен, в него верят, а в продакшене он либо не выполнялся ни разу, либо выполнялся не так, как все были уверены.
Дальше про приборы. Ничего изощрённого в них нет: это наблюдаемость уровня «посчитать строки в логе, который и так пишется», Prometheus у нас не завёлся до сих пор, и половина статьи про то, как мы столько времени жили вообще без этого. Обход блокировок тут просто предметная область, приёмы работают на любом коде, который включается редко и по чужому расписанию: ретраи, фолбэки, аварийные переключения, резервные каналы доставки.
Приём первый: посмотреть в лог, который и так пишется
Первый вопрос звучал наивно. Чем люди до нас доходят?
У нас три пути, и долей мы не знали ни у одного. Отвечать было нечем не потому, что данных нет, а потому что никто не смотрел. Каждый запрос уже приходит одним из трёх путей, и адрес источника это говорит:
-
источник это адрес одной из наших релейных машин, значит запрос вышел из туннеля;
-
источник это краевой узел Cloudflare, значит клиент шёл через фронт (реальный адрес лежит в
Cf-Connecting-Ip); -
всё остальное это прямое соединение.
Классификатор занимает десять строк:
CF_RANGES = ["173.245.48.0/20", "103.21.244.0/22", ...] # www.cloudflare.com/ips-v4def classify(ip: str, relay_ips: set[str], cf_nets) -> str: if ip in relay_ips: # relays.yaml, тот же файл, из которого return "relay" # подписывается конфиг для клиентов try: addr = ipaddress.ip_address(ip) except ValueError: return "direct" return "front" if any(addr in n for n in cf_nets) else "direct"
def classify(ip: str, relay_ips: set[str], cf_nets) -> str: if ip in relay_ips: # relays.yaml, тот же файл, из которого return "relay" # подписывается конфиг для клиентов try: addr = ipaddress.ip_address(ip) except ValueError: return "direct" return "front" if any(addr in n for n in cf_nets) else "direct"
Дальше проход по журналу Caddy в JSON, счётчик на каждый путь, и всё. Ни строки на клиенте, ни одного нового поля в базе, никаких внешних сервисов: geoip у нас свой, потому что отправлять таблицу «кто откуда стучался в RCQ» третьей стороне ради красивого отчёта это ровно тот артефакт, который наш проект создавать не должен.
Вот что получилось за час (почему за час, а не за сутки, будет ниже), примерно 65 000 запросов приложения:
|
путь |
доля |
|---|---|
|
релеи |
71 % |
|
прямое |
29 % |
|
фронт через CDN |
0.2-2 % |
Мы обсуждали обход как аварийный слой, а по нему идёт две трети работы приложения. Это была самая дорогая строчка за оба дня, и стоила она ноль: данные лежали в логе всё это время. Здоровье пула релеев после такой таблицы перестало быть страховкой и стало несущей конструкцией, а сутки неработавшего автопереключения (про них дальше) выглядят задним числом гораздо страшнее, чем выглядели тогда.
Три способа обмануть себя этим же прибором
Прибор врёт охотнее, чем кажется, и все три раза я поверил ему раньше, чем проверил.
Окно измерения не равно запрошенному. Caddy ротирует лог по размеру (roll_size 10mb, roll_keep 5), и при нашем объёме сохранённые файлы покрывают около часа. Скрипт получал --hours 24, честно фильтровал по времени и печатал «за сутки» поверх часа. Хуже того, за вечер я наблюдал, как доля релеев растёт: 53, 62, 67, 73 процента. Никакого тренда там не было, это четыре разных окна разной длины. Теперь снимок несёт поле covered_hours, скрипт ругается, если покрытие меньше запрошенного, а панель в админке подсвечивает такие снимки жёлтым.
Механизм измеряет сам себя. Селектор релеев (urltest в sing-box) дёргает /health через каждый релей каждые пять минут на каждом клиенте. Это 3573 запроса в сутки, 76 % из них через релеи. В общей куче релеи выглядят несущими приложение, а несут они измерение самих себя. Первый прогон был с этой ошибкой, и доля релеев в нём получилась заметно красивее.
PROBE_PATHS = ("/health",)...if uri in PROBE_PATHS: probe_transport[kind] += 1 continue # считаем отдельно, в основную долю не пускаем
...
if uri in PROBE_PATHS: probe_transport[kind] += 1continue # считаем отдельно, в основную долю не пускаем
Страна в таблице это страна выходного узла. У нас MD даёт 10 076 запросов с трёх адресов, AE 3649 с одного. Это не «Молдова активно пользуется», это чей-то VPN или отдельный тяжёлый клиент. Единственная строка, которую можно читать как страну, выглядит так: RU, 5407 запросов, 20 адресов.
Что он нашёл сразу: фронт, который никто не пробует
Фронт (наш домен за Cloudflare, запасной путь на случай блокировки самого острова) нёс 1.6 %, и на фоне семидесяти у релеев это выглядело как поломка, с которой надо срочно что-то делать.
Большая часть объяснения оказалась скучной и правильной. Фронт намеренно пропускается, когда туннель поднят: релей прячет адрес пользователя от нашего же сервера, а фронт не прячет, его видит Cloudflare. Молча переводить на фронт человека, который осознанно включил туннель, нельзя. Плюс в РФ прямое соединение пока работает: 4146 запросов из РФ за окно, через фронт из них ноль, с 21 адреса. Почти весь фронт-трафик, что был, шёл с одного адреса в Чехии.
А вот дальше была настоящая дыра. Проверка после подъёма туннеля откатывалась только на прямое соединение:
if (!routeOk && !transport.localProxyMode() && !transport.isOnionOptIn(appCtx) && transport.probeDirect(serverHost())) { /* уходим на прямое */ }
transport.probeDirect(serverHost())) { /* уходим на прямое */ }
Установка, у которой заблокированы и все релеи, и адрес острова, оставалась на туннеле, который ничего не несёт, а единственный уцелевший путь не пробовался вообще. Снаружи включённый туннель выглядит как приватность. Изнутри это приложение, которое не открывается. И форма сети, при которой это случается, не экзотическая: режут наши адреса, Cloudflare не трогают.
Починка вышла в v0.91, коммит b185e5f: та же ветка, те же ограничения (никогда под чужим локальным прокси, никогда при явном выборе онион-режима), плюс пинок сокету пушей, чтобы он тоже переехал.
Ветка эта прожила месяц, и ни тест, ни ревью её не поймали бы: код в ней корректный, а чтобы написать на неё тест, надо сначала предположить сеть, где мертво и то, и другое. Видно её только из вопроса про доли, а такой вопрос задаёшь, когда у тебя перед глазами таблица.
Приём второй: запустить механизм, а не прочитать его
У нас есть канарейка. Каждые десять минут она обходит флот, и если релей перестал отвечать, подписывает новый конфиг без него и публикует. Автоматическое переключение, гордость архитектуры.
Скрипт подписи, который она для этого вызывает, оказался третьей копией самого себя (рабочая на ноутбуке, каноническая в репозитории, боевая на сервере). Он отстал от остальных и на актуальном relays.yaml просто падал:
$ sign-relay-config.py relays.yaml --key …unknown source type: dns-txt exit=1
unknown source type: dns-txt exit=1
Сутки канарейка не могла подписать вообще ничего. При падении релея она вызывала скрипт, получала пустоту, писала в лог «republish FAILED» и оставляла флот на конфиге, где мёртвый релей числится живым, а проявилось бы это только в момент настоящей аварии.
Читая код, это не увидишь: код правильный. Видно только прогоном на настоящем входе. Мы теперь гоняем симуляцию аварии руками: подписать подмножество из 13 релеев вместо 14 и убедиться, что подпись сходится клиентской канонизацией.
Там же выяснилась деталь, которую никто из нас не помнил. Канарейка сравнивает набор тегов, а не содержимое. Смена SNI, ключей или портов при том же наборе тегов не вызывает публикации никогда, такие изменения надо подписывать руками мимо неё и синхронизировать номер версии в её состоянии, иначе она потом опубликует поверх.
Приём третий: спросить, какая строка появляется в базе
Самый простой прибор из всех. У любого механизма спросите, что материально остаётся после его срабатывания, и посчитайте это в базе.
У нас есть приглашения. Эндпоинт record_referral пишет строку и связывает пригласившего с приглашённым взаимными контактами. Написан давно, работает (проверили живой регистрацией: два аккаунта стали контактами мгновенно).
В таблице referrals ноль строк за всю жизнь проекта. При этом 75 % аккаунтов не имеют ни одного контакта.
Разрывов оказалось три, и каждого хватало для нуля. Регистрация не передавала пригласившего: поле inviter_uin существовало и всегда было null. Ожидающее добавление по ссылке потреблялось только уже зарегистрированной сессией, то есть именно тот человек, ради которого функция написана (перешёл по ссылке друга, поставил, завёл аккаунт), приходил с пустым списком, а приглашение молча терялось. И пригласить того, у кого RCQ ещё нет, было нечем: кнопка «поделиться» отдавала APK на 100 мегабайт.
Страница rcq.app/r/<uin>, куда ведёт ссылка-приглашение, была построена месяцами раньше, и за всё время ни один клиент её не забрал. Нажатие открывало браузер, и на этом приглашение заканчивалось.
Финальную деталь я поймал случайно, уже запустив сборку на эмуляторе. Плашку с приглашением я положил на пустой экран, который рисуется при отсутствии контактов, групп и заявок. А каждый новый аккаунт у нас автоматически попадает в группу «RCQ Beta» на 1826 человек, поэтому список групп не пуст никогда, и пустой экран не видит вообще никто. Значимое состояние другое: группы есть, своих контактов нет, и это состояние трёх четвертей базы.
Приём четвёртый: измерить систему сразу после того, как её тронули
Мы перевели домен пуш-сервера на Cloudflare: изменение на пять минут, оранжевое облако в панели, ни релиза, ни рестарта, вообще ничего похожего на работу.
За первый час после переключения: 129 отказов 429 на подписку. За предыдущие тринадцать часов их было ноль.
ntfy лимитирует по адресу посетителя и читает X-Forwarded-For. А в нашем конфиге стояло header_up X-Forwarded-For {remote_host}, то есть Caddy затирал настоящий адрес клиента адресом краевого узла CF. Весь парк устройств начал делить несколько бакетов на всех, а счётчик известных серверу посетителей рос примерно на 12 в минуту, потому что каждый новый краевой узел это новый посетитель. Вторая причина рядом: остров публиковал пуш-сигналы на собственное публичное имя, то есть приходил сам к себе с адреса Cloudflare и перестал попадать в список хостов, освобождённых от лимитов.
Лечится это двумя правками (доверять Cf-Connecting-Ip только с диапазонов CF, потому что origin достижим и напрямую, иначе любой назовётся кем угодно и потратит чужой бакет; а серверный хоп увести на loopback), но интересно не это. Интересно, что переключение выглядело безобидным и никто бы не пошёл смотреть логи ntfy, если бы прибор не появился накануне.
Заодно выяснилось, что reload Caddy рвёт все сокеты пушей разом: 129 подписчиков превратились в 3, и возвращались они двадцать пять минут, потому что телефон в Doze не передозванивается до следующего окна обслуживания. Теперь reload на пуш-хосте планируется как операция с последствиями, а не как безобидная перечитка конфига, каковой он является для всего остального.
Приём пятый: мерить оттуда, где механизм будет работать
Reality (режим маскировки в sing-box) выдаёт наш трафик за обращение к чужому популярному сайту, и выбранное имя обязано отдавать TLS 1.3, X25519 и h2. Мы проверяли кандидатов с ноутбука, а надо было с самой машины: у одного облака выбранное имя отдавало P-256, а популярный java.com вообще не умеет TLS 1.3. Про сам выбор имени скажу коротко, потому что это тема отдельной статьи: оно должно совпадать по ASN с адресом машины, иначе все ваши соединения склеиваются в одну группу по несовпадению.
Пока чинил, потерял часа полтора на две вещи, обе смешные.
Первая. Вход по ключу на машину в GCP не работал дважды подряд, при том что ключ лежал где надо. GCP берёт имя пользователя из комментария в конце публичного ключа. Строка вида ssh-ed25519 AAAA… rcq-relay зарегистрировала ключ на пользователя rcq-relay, которого на машине нет. Тот же ключ с другим комментарием, и вход работает. Я до сих пор считаю это самым неожиданным местом для хранения имени пользователя.
Вторая. Отложенный откат конфига я запустил через nohup … & по ssh. Он сработал не тогда, когда я ждал: я успел решить, что его нет вовсе, опубликовал изменение вперёд, и через минуту сторож откатил машину под уже опубликованным конфигом. Расхождение получилось дважды и в обе стороны. Для отложенного отката теперь systemd-run --on-active, а nohup через ssh я за сторожа больше не считаю.
Мина, которую прибор нашёл сам
Локальный relays.yaml, из которого подписывается конфиг для всех клиентов, отставал от живого на 121 версию и всё ещё нёс московский релей, снятый в июне, плюс пару машин в облаке, которого у нас давно нет.
Ни один клиент не отвергал конфиг с версией ниже уже известной: Android читал version только для диагностики. То есть один прогон подписи из этого файла раздал бы всему флоту мёртвый релей и откатил бы фактическую версию, молча и с корректной подписью.
Теперь подписчик перед работой тянет живой конфиг и отказывается подписывать версию не выше текущей (обходится флагом, потому что офлайн-подпись должна остаться возможной), а клиент проверяет минимальную версию при разборе. Подпись доказывает, что payload наш, но не доказывает, когда он выпущен, и старый подписанный конфиг это готовая атака для того, кто контролирует зеркало.
Чего эти приборы не дают
Границы у всего этого широкие, и лучше я назову их сам, чем это сделают в комментариях.
Мы не видим ничего за релеем, и не должны: релей стоит между нами и человеком ради этого. Долю их трафика посчитать можно, а что происходит за ними, закрыто для нас той же стеной, которая закрывает это от всех остальных.
Мы не видим тех, у кого не открылось. Самый важный класс пользователей (те, у кого не сработало вообще) в лог не попадает никогда, потому что до лога не доехал. Про них мы узнаём только из жалоб, и это худший из возможных каналов обратной связи.
Час лога это не сутки, и снимки сравнимы между собой только с оглядкой на покрытие. Страна это выходной узел. Всё вместе это меряет наш прод, то есть проверить наши цифры снаружи нельзя, можно только повторить приём у себя.
И честно про сам вывод: восемь механизмов, которые не выполнялись, мы нашли за двое суток вовсе не потому, что стали умнее. Мы просто впервые посмотрели. Сколько таких же осталось в местах, куда прибор ещё не доехал, я не знаю.
Вот с этим и не понимаю, что делать. Прибор на тех, у кого приложение не открылось, поставить негде: они по определению до нас не дошли. Если кто-то решал эту задачу не через жалобы пользователей, расскажите как.
ссылка на оригинал статьи https://habr.com/ru/articles/1067252/