Обход блокировок: как белый список и метрики дают ложное «ok» в двухпрыжковой схеме

Если вы поднимаете собственный "обход блокировок", белый список у вас почти неизбежен. Чаще всего он даже не оформлен как отдельная сущность - это просто последнее правило в конфиге файрвола: разрешить конкретные адреса и сервисы, а всё остальное без разговоров отправить в reject. Такой подход появляется очень быстро: открытый прокси в интернете живёт ровно до первой жалобы хостеру, поэтому "всем всё можно" заканчивается в тот же день, когда система хоть немного становится популярной.

И вот как выглядит ловушка: узлы добавляются, конфиги раскатываются, туннели поднимаются, мониторинг светится зелёным... а спустя недели внезапно выясняется, что четыре узла из семи фактически не работают, и при этом все проверки продолжают говорить "OK". Вопрос не в том, что "сломались метрики". Вопрос в том, что метрики измеряли не то.

Что за система и почему "всё было нормально"

RCQ - мессенджер, который умеет обходить блокировки без отдельного VPN: внутри используется sing-box, снаружи - Reality. На 10 августа "флот" состоял из 14 эндпоинтов на семи машинах у четырёх провайдеров. Клиент забирает подписанный конфиг (версия 144) одним запросом, раскладывает его по urltest - а дальше urltest сам выбирает, через что идти, сравнивая доступные маршруты.

Начиная с версии 0.80~ трафик пошёл в два прыжка. Входной узел знает, кто вы, но не знает, куда вы идёте. Выходной узел знает, куда вы идёте, но не знает, кто вы. Классическая схема, к которой пришли после неприятного осознания: один релей видел и IP клиента, и конечный "остров", то есть ту самую связку, ради разрыва которой всё и затевалось.

Но двухпрыжковая архитектура добавляет требование, которого раньше не существовало вовсе: вход обязан уметь дозваниваться до выхода. А вход - это такая же "запертая" машина с белым списком и reject в конце. Значит, если выход не перечислен в разрешённых - цепочка просто не соберётся.

Симптом был, но смотрели не туда

Поводом копать стала жалоба: платный узел, за который человек заплатил деньги, упорно не хотел становиться выходом цепочки. Клиент строил маршрут, urltest его отбраковывал, и трафик уходил через публичные машины. Первой мыслью были приоритеты в клиентском коде - я полез в код, потратил вечер и весь этот вечер смотрел мимо проблемы.

Реальная причина оказалась приземлённой и от этого особенно обидной. На живом входе правила выглядели примерно так (адреса заменены на буквы):

- остров-1/32 → direct
- остров-2/32 → direct
- узел-F/32 → direct
- узел-G/32 → direct
- (всё остальное) → reject

То есть в белом списке - всего пять адресов: два "острова" и два релея (плюс ещё один сервисный адрес). Остальных узлов флота в списке не было вообще. А четыре из них, кстати, стояли у одного провайдера - так что последствия для двухпрыжковой схемы можно было прикинуть буквально по этой пятёрке: входам просто некуда было "выйти".

Дальше всплыло ещё веселее: списки на разных машинах оказались разными. Из семи опубликованных узлов взаимно достижимы были трое. Для бесплатного пользователя, чей вход попадал в "неудачную" группу, выходов в реальности существовало два. А "онион", который на картинке выглядел как сеть из семи машин, гонял его трафик через три - просто потому, что остальные были отрезаны правилами.

Почему мониторинг не кричал

Потому что urltest не обязан ругаться на мёртвые цепочки - он их просто не выбирает. В этом и смысл: собрать маршруты, замерить, оставить живые. Если из двенадцати возможных цепочек девять не поднимаются, туннель всё равно встанет по трём оставшимся. В логах может не появиться ни строчки, потому что с точки зрения алгоритма ничего не "сломалось": он сделал ровно то, что должен - выбрал из доступного.

"Канарейка" проверяла снаружи, что релей отвечает - релей отвечал. Внешний зонд проверял, что "остров" жив - остров был жив. Оба инструмента работали правильно. Они честно мерили доступность отдельных компонентов, а не работоспособность всей цепочки как системы.

Был и третий инструмент, написанный как раз "на всякий случай": `relay-lockdown.sh --check`. Он сверял, что в правилах разрешены маскарадный хост, зеркала подписанного конфига, DoH-резолверы, хост пробы. Но флот он не сверял. Поэтому на каждой из семи машин лежал скрипт, который печатал `ok: masquerade host <...> and every named mirror are allowed` - и формально не врал ни одним словом.

Ничего не было "неисправно". Просто ни один прибор не смотрел именно туда, где происходила деградация. И, что ещё хуже, не было человека (или процесса), который удерживал бы в голове всю картину целиком: от топологии до правил доступа между ролями "вход/выход".

Приватные узлы: другая ошибка, более опасная

Раз уж полез в инфраструктуру, заодно проверил два приватных узла. Это арендованные машины: организация платит, узел принадлежит ей одной, в публичной выдаче его нет никогда - иначе аренда теряет смысл. Экономика там не про маржу, а про рост: аренда стоит около $60 в месяц, железо под неё - примерно $15, а обычный публичный узел обходится где-то в $5. Один платящий фактически тянет ещё примерно девять узлов для тех, кто платить не может, и это единственный понятный способ расти, когда ваши семь адресов статичны месяцами и при желании цензора закрываются за вечер (утрирую, но направление верное).

Так вот: у обоих приватных узлов не было секции route вообще. Ни белого списка, ни reject - ничего. Это означало, что любой, у кого есть ключ арендатора, мог ходить через нашу машину куда угодно. Риск здесь бытовой и одновременно катастрофический: от превращения узла в "пылесос" для запрещёнки до жалоб хостеру, блокировок подсетей и репутационных последствий для всей инфраструктуры.

Где всплывает "мелочь про SNI"

На таких системах регулярно проявляются "неважные детали", которые на практике оказываются триггерами массовых отказов. SNI - одна из них: даже если транспорт выглядит корректно, несоответствие ожидаемого имени в рукопожатии, маскараде или проверках может давать странную картину "всё пингуется, но не работает". Особенно когда часть узлов уже за белыми списками и любые отклонения от заранее разрешённого профиля превращаются в молчаливый reject. В результате симптомы плавают: где-то urltest "не выбирает", где-то рукопожатие "как будто проходит", а пользователь просто видит деградацию без очевидной причины.

Что стоит сделать, чтобы это не повторилось (и почему "OK" больше не должен быть успокоением)

1) Проверять не узлы, а маршруты. Нужен синтетический тест, который из роли "вход" реально строит цепочку до роли "выход" и делает контрольный запрос наружу. Не "релей отвечает", а "вход → выход → интернет работает". И так - для всех сочетаний, которые допускает конфиг.

2) Внутренние канарейки, а не только внешние. Внешний мониторинг хорош, но он никогда не увидит, что вход не может достучаться до выхода из-за собственных правил. Минимум одна проверка должна жить внутри периметра и смотреть на связность ролей.

3) Единый источник правды для белых списков. Разные списки на разных машинах - это почти гарантированная деградация. Нужна централизованная генерация правил из одного описания флота и автоматическая раскатка. Любое ручное "вставил IP в одном месте" со временем превращается в минное поле.

4) Отдельный контроль "полноты" списка. Скрипт, который говорит "ok, разрешены зеркала и DoH", полезен, но недостаточен. В двухпрыжковой схеме он обязан дополнительно сверять, что *каждый вход* разрешает *каждый выход*, или хотя бы актуальный набор выходов по своей группе. Иначе вы проверяете не безопасность/работоспособность, а лишь аккуратность отдельных строк.

5) Сигнализировать о деградации, даже если сервис "не упал". Если из 12 цепочек работает 3 - это не "нормально". Это потеря ёмкости, снижение устойчивости и ускорение блокировок (потому что трафик концентрируется на меньшем числе адресов). Такие вещи должны поднимать предупреждение.

6) Регламент на изменения в топологии. Двухпрыжковость меняет требования к сетевой связности радикально. Любое добавление узла должно автоматически тянуть изменение в ACL/route. И это должно быть не в голове одного человека, а в процедуре: добавил узел → CI проверил матрицу достижимости → только потом в прод.

7) Разделить ответственность по ролям, а не по "грейду". Здесь легко ошибиться, думая, что "мидл - это три года опыта". На практике такие аварии ловятся не стажем, а наличием ответственности за целостность системы: кто-то должен владеть картиной целиком - от конфигов до наблюдаемости и сценариев отказа.

8) Не позволять моде на "автоматизацию и ИИ" подменять инженерную дисциплину. Когда принятие решений парализуется ожиданием "сейчас мы всё оптимизируем", в инфраструктуре копятся мелкие несостыковки: списки расходятся, проверки не обновляются, контрольные сценарии не пишутся. В итоге вы получаете идеальный шторм: технически всё "по отдельности" работает, а система - нет.

Главный вывод неприятный: зелёные галочки на узлах не означают зелёную систему. Особенно если ваша архитектура - это цепочка, где работоспособность определяется не состоянием компонентов, а их связностью и правилами, которые эту связность разрешают или запрещают. Когда четыре узла из семи отрезаны, а мониторинг радостно рапортует "OK", это не баг мониторинга. Это честный ответ на неправильный вопрос.

Прокрутить вверх