Многие сервисы сегодня выдают клиенту не один конкретный конфиг для подключения, а VLESS/Trojan-подписку — ссылку, за которой стоит целый пул из десятков серверных конфигураций сразу. Провайдеру это удобно (не нужно вручную вести отдельный конфиг под каждого клиента), но у пользователя тут же встаёт главная практическая проблема: подключить роутер именно через такую подписку так, чтобы соединение работало стабильно. Один конкретный сервер из пула сегодня быстрый, завтра перегружен, послезавтра вообще не отвечает — а подписка в целом при этом продолжает исправно работать, просто нужно каждый раз заново находить, какой именно из десятков серверов сейчас реально живой.
Решает это балансировщик — механизм, который сам, постоянно, в фоне проверяет каждый сервер пула подписки и автоматически выбирает самый быстрый живой, без участия человека.

Об этом и статья: что такое точечная (доменная) маршрутизация на роутере, зачем нужна автоматическая балансировка между серверами одной VLESS/Trojan-подписки, и как это устроено в нашем проекте SmartRoute — модуле для роутеров на OpenWrt и KeenticOS (через xkeen), который умеет и то, и другое из коробки. (Ps. статья еще дописывается, скоро тут будут скрины всех шагов установок + видео инструкции установок под разные роутеры)
Содержание
Репозиторий: https://github.com/LackyCraft/xkeen-smartroute
Вдохновлено «Настройка роутера с VPN по доменному принципу» — но со своим стеком, своим балансировщиком и без единой точки отказа в виде одного сервера.
Что такое точечная маршрутизация
Обычный VPN-клиент на роутере — это рубильник: либо весь трафик всех устройств идёт через туннель, либо не идёт совсем. Это неудобно сразу по нескольким причинам: часть сервисов работает быстрее напрямую, часть устройств (умный дом, IoT) вообще не должна ходить через туннель, а часть трафика (например, видеозвонки) чувствительна к задержке, которую туннель добавляет.
Точечная (доменная) маршрутизация решает это иначе: трафик пускается через туннель не целиком, а по правилам — “домены из категории X → через сервер/группу Y”, “это устройство → всегда напрямую”, “эти IP-диапазоны → через конкретный узел”. Роутер сам смотрит на SNI/Host заголовок TCP-соединения (или на IP назначения — для трафика, который вообще не выдаёт домен), сверяет со списком правил и решает, куда пустить именно этот поток.
Технически это работает поверх xkeen — менеджера ядра Xray-core для роутеров на Entware — и представляет собой набор правил routing.rules, которые Xray сопоставляет на лету для каждого нового соединения.
Проблема балансировки: почему один сервер — плохая идея
Если у вас подписка с одним-двумя серверами — балансировать особо нечего, просто настройте маршрут на них. Но реальные провайдеры давно продают VLESS/Trojan-подписку на пул серверов: одна ссылка — десятки узлов в разных дата-центрах, обновляемых на лету. Здесь и начинается проблема:
Часть серверов в любой момент времени объективно перегружена или временно недоступна — это нормальная жизнь дата-центров, не аномалия.
Провайдер приватной сети физически не может гарантировать 100% аптайм каждого отдельного узла — только то, что пул в целом живой.
Если зашить в конфиг роутера один конкретный сервер вручную, рано или поздно он “ляжет”, и весь профиль маршрутизации молча перестанет работать, пока вы не заметите и не поменяете вручную.
Значит, нужен механизм, который сам, постоянно, в фоне проверяет: какие серверы пула сейчас реально живы, насколько быстро они отвечают, и автоматически выбирает лучший — без участия человека. Это и есть балансировка. Дальше в статье — как мы её реализовали, потому что готовое решение внутри самого Xray-core оказалось сломанным (подробности и код — ниже).
Что делает SmartRoute

SmartRoute — модуль поверх xkeen, добавляющий слой управления маршрутизацией через веб-интерфейс (LuCI-модуль + отдельная живая панель). Идея в том, чтобы полностью автоматизировать всё, что происходит между «вот моя подписка» и «трафик реально пошёл по нужным серверам»: от пользователя требуется только вставить ссылку на подписку и создать правила — например, «сайт1 → через сервер/группу A», «сеть2 → через сервер B» — а дальше эти правила просто начинают работать сами. Всю техническую часть — импорт и парсинг подписки, генерацию и валидацию конфигов, перезапуск и мониторинг живости Xray, выбор лучшего сервера в группе — берёт на себя SmartRoute, вручную редактировать конфиги xkeen/Xray не нужно. Кратко, что внутри:
Профили маршрутизации — связка “домены (по категории или свой список) и/или устройства (IP/CIDR) и/или IP-диапазоны” → один конкретный сервер или группа с автовыбором самого быстрого живого.
Собственный алгоритм выбора сервера в группе — не встроенный
leastPingXray (он сломан в используемой версии ядра, подробности ниже).Policy-Based Routing по устройствам — “весь трафик именно этого устройства”, независимо от домена или вместе с ним.
Маршрутизация по IP/CIDR-диапазонам — для протоколов без SNI и DNS, поддерживает вставку/загрузку готовых списков в формате экспортированных статических маршрутов Keenetic (
.bat).Импорт подписки (VLESS/Trojan) — с обходом anti-bot-проверок на стороне провайдера подписки (17 профилей клиента перебираются автоматически).
Автообновление подписки по расписанию, без потери уже настроенных профилей при сбое.
Пинг + Observatory — настоящая проверка живости каждого сервера, а не просто “порт открыт”.
Свои списки доменов на лету — для того, чего нет в готовых доменных категориях.
Kill-switch без окна уязвимости — постоянно взведённое правило firewall, а не проверка “жив ли процесс” раз в минуту.
Резервный канал для инфраструктурного трафика — прогон всего трафика через один заведомо стабильный узел пула, когда прямой путь до конкретного сервера ненадёжен (подробнее ниже).
Защита от утечек — DNS, IPv6, QUIC/HTTP3.
Устойчивый перезапуск — конфиг валидируется перед каждым применением, один битый узел подписки не роняет весь стек.
Работает на чистом OpenWrt, не только на KeenticOS — свой перехват трафика, не завязанный на встроенный механизм
xkeen, который на nftables-платформах не работает (подробнее ниже).Отдельная живая панель — график трафика, метрики здоровья серверов, логи по требованию, как альтернатива LuCI на том же backend’е.
Интерфейс на русском и английском.
Быстрый старт
Установка — одной командой по SSH на роутере:
sh <(wget -q -O - https://raw.githubusercontent.com/LackyCraft/xkeen-smartroute/master/install.sh)
Обновление — та же команда, скрипт идемпотентен:
sh <(wget -q -O - https://raw.githubusercontent.com/LackyCraft/xkeen-smartroute/master/install.sh)
Удаление:
sh <(wget -q -O - https://raw.githubusercontent.com/LackyCraft/xkeen-smartroute/master/uninstall.sh)
Как это работает технически (коротко)
Весь пайплайн от подписки до реального трафика укладывается в одну строку:
подписка → servers.json → UI (домен/устройство/IP × сервер/группа) → routing.json → Xray → трафик
Подписка импортируется и парсится в список серверов (
servers.json).В UI вы создаёте профили: что маршрутизировать (домены/устройства/IP) и куда (конкретный сервер или группа с автовыбором).
Генератор конфига (
genroute.sh) превращает каждый профиль в правилоrouting.rulesдля Xray, выбирая для групп-балансеров лучший сервер прямо сейчас.Xray перезапускается только если что-то реально изменилось, и начинает применять новые правила к трафику.
Установка одной командой: пошагово
Скрипт полностью автоматический и идемпотентный — его можно запускать повторно для обновления. Внутри шесть последовательных шагов, каждый пишет прогресс в консоль:
Шаг 1/6 — Entware. Проверяется, установлен ли пакетный менеджер Entware (opkg); если нет — ставится с нуля вместе с хуком автозапуска, чтобы весь стек переживал перезагрузку роутера.

Шаг 2/6 — xkeen. Ставится сам менеджер Xray-core (Skrill0/XKeen), при необходимости — заменяется на сборку Xray, совместимую с CPU роутера.

Шаг 3/6 — xkeen-UI. Отдельная веб-панель для сервисных задач (порт 1000). На этом шаге скрипт интерактивно спросит пароль для входа — рекомендуется тот же, что и для самого роутера.

Шаг 4/6 — сам SmartRoute. LuCI-модуль и генераторы конфигов — основная логика проекта.

Шаг 5/6 — smartroute-gateway. Живая панель (порт 1001), альтернатива LuCI — качается уже собранный бинарник под архитектуру роутера. Тоже спросит пароль (можно оставить пустым, но тогда панель будет доступна всем в локальной сети без авторизации).

Шаг 6/6 — cron. Настраиваются периодические задачи: обновление доменных списков раз в 8 часов, обновление подписок по расписанию, ночной плановый рестарт Xray (актуально при больших подписках, где процесс может со временем разрастись по памяти).

По завершении скрипт печатает адреса всех интерфейсов:
LuCI: http://<IP-роутера>/ -> Services / Сервисы -> XKeen SmartRoute xkeen-UI: http://<IP-роутера>:1000/ Панель: http://<IP-роутера>:1001/
Дальше: Подписки → вставить ссылку на VLESS/Trojan-подписку → «Импортировать», Профили → создать первое правило маршрутизации, Kill-Switch → включить защиту для профиля.

Технические моменты
Маршрутизация по доменам и проблема трафика без SNI
Базовый механизм точечной маршрутизации — сопоставление домена (SNI в TLS ClientHello или Host в HTTP) со списком правила. Но не весь трафик вообще несёт домен: часть протоколов (например, любые приложения, использующие собственные транспортные протоколы поверх сырого IP, без TLS SNI и без DNS-резолва на устройстве) физически невозможно опознать по домену — Xray просто не видит в пакете ничего похожего на имя хоста.
Для этого случая в профиле есть отдельное поле — IP/CIDR-диапазоны. Правило “этот трафик — по IP назначения, а не по домену” генерируется отдельным объектом маршрутизации, а не смешивается с доменным (Xray AND-ит поля одного правила, а нужен OR: “либо домен из списка, либо IP из диапазона”):
build_rule_list() { domains="$(domain_match_array "$1")" devices="$(device_match_array "$1")" ips="$(ip_match_array "$1")" jq -n --argjson d "$domains" --argjson s "$devices" --argjson i "$ips" ' [ (if ($d|length) > 0 or ($i|length) == 0 then {} + (if ($d|length) > 0 then {domain:$d} else {} end) + (if ($s|length) > 0 then {source:$s} else {} end) else empty end), (if ($i|length) > 0 then {ip:$i} + (if ($s|length) > 0 then {source:$s} else {} end) else empty end) ] ' }
Два отдельных объекта правила, указывающих на один и тот же outboundTag, дают “домен ИЛИ IP” бесплатно — за счёт того, что Xray берёт первое совпавшее правило по порядку массива.
Поле IP-диапазонов принимает не только голые CIDR-записи, но и строки в формате экспортированных статических маршрутов Keenetic (route ADD <сеть> MASK <маска> <шлюз>) — это тот же текст, что лежит в .bat-файлах готовых списков диапазонов сервисов, массово гуляющих в сети. Разбор и конвертация в CIDR происходят целиком на клиенте, до отправки на backend: маска побитово проверяется на то, что она — непрерывный блок единиц, затем нулей, замыкающий IP-адрес (в оригинальном формате — адрес шлюза политики Keenetic) игнорируется как чужой артефакт формата.
Почему мы не используем встроенный балансировщик Xray
У Xray-core из коробки есть механизм routing.balancers с алгоритмом leastPing. Казалось бы, готовое решение — но в реально используемой версии ядра он не работает: наличие любого правила с balancerTag в routing.rules полностью останавливает совпадение всех правил маршрутизации целиком, не только самого правила балансера. Баг воспроизведён на живом железе методом исключения, заведён апстрим: XTLS/Xray-core#6642.
Поэтому вместо balancerTag мы на своей стороне (в shell/jq) вычисляем “кто сейчас лучший сервер группы” и эмитим обычное правило outboundTag — тот же механизм, что уже подтверждённо работает для фиксированного сервера. Полностью, как именно мы это делаем — отдельный большой раздел ниже, это ядро проекта.
Резервный канал для инфраструктурного трафика
Отдельная функция — опциональный дополнительный “хоп” перед всеми остальными серверами. Идея простая: сеть между роутером и конкретным сервером подписки не всегда стабильна одинаково для всех серверов пула — но почти всегда среди пула есть хотя бы один узел с надёжным, стабильным путём. Если весь остальной трафик (и пользовательский, и собственные фоновые проверки живости серверов) пустить сначала через этот один надёжный узел как через шлюз, а уже с него — до конечного сервера, нестабильность конкретных сетевых путей до отдельных destination-адресов перестаёт иметь значение.
Технически это не отдельный самодельный туннель, а штатная возможность Xray-core — цепочка outbound’ов через streamSettings.sockopt.dialerProxy:
if len(sockopt.DialerProxy) > 0 { obm := ... h := obm.GetHandler(sockopt.DialerProxy) return redirect(ctx, dest, sockopt.DialerProxy, h), nil }
Когда outbound A пытается подключиться к своему настоящему адресу, и у A выставлен dialerProxy на тег outbound’а B — вместо прямого соединения запрос идёт через B: B физически устанавливает связь до адреса A, а поверх уже накручивается протокол самого A. Поскольку этот механизм диспетчеризации общий для любого дайла через outbound, он автоматически работает и для фоновых проверок живости (Observatory, см. ниже) — без единой правки кода проверки, чисто на уровне генерации конфига.
Выбор, какой из узлов-кандидатов пула сейчас лучше всего годится на роль такого промежуточного шлюза, делает тот же самый алгоритм балансировки, что и обычные профили-группы — раздел ниже.
Наш балансировщик подробно
Это самая проработанная часть проекта — постоянная балансировка требует постоянной проверки живости серверов, а проверка живости требует постоянного перезапуска Xray для применения новых данных. Проблема в том, что при каждом перезапуске Xray забывает всё, что уже проверил. Ниже — как мы обошли это ограничение.
Два независимых сигнала: пинг и Observatory
1. Быстрый TCP-пинг. Голое TCP-подключение к host:port сервера, без какого-либо протокола поверх. Быстро (сотни миллисекунд на сервер), но проверяет только “порт открыт и принимает соединение” — ничего не говорит о том, реально ли работает VLESS/REALITY поверх него. Сервер со сломанным TLS-камуфляжем отвечает на голый TCP-коннект мгновенно и выглядит живым, хотя настоящий защищённый трафик через него не пройдёт.
2. Observatory (app/observatory в самом Xray-core) — настоящая проверка: Xray сам, изнутри, устанавливает полное защищённое соединение через конкретный outbound и делает через него запрос на тестовый URL. Это ровно то же самое, что происходит при реальном подключении пользователя.
Механика (из реального исходника app/observatory/observer.go):
Конфиг задаёт
probeUrl,probeIntervalи список тегов outbound’ов для проверки (subjectSelector) — этот список у нас не статичный, подробнее ниже.На каждый тег: полный хендшейт через именно этот outbound, затем HTTP-запрос. Два жёстких таймаута по 5 секунд — на TLS-хендшейк и на весь запрос целиком.
Сервер считается живым тогда и только тогда, когда запрос не вернул ошибку транспорта. HTTP-статус-код ответа не проверяется вообще. Мёртвым делает только уровень транспорта: отказ в соединении, таймаут хендшейта, обнаруженная подмена сертификата, обрыв соединения.
Задержка — реальное wall-clock время от отправки запроса до закрытия тела ответа, включая хендшейт — не голый пинг.
Пробы идут строго последовательно, никогда параллельно — на слабом железе без свопа параллельные TLS-хендшейты по нескольким outbound’ам разом роняли роутер в OOM. Пауза между пробами выставлена в практический ноль (
"1ms"— буквальный ноль Xray интерпретирует как “используй дефолт 10 секунд”, это отдельная особенность реализации).
Реальные цифры с живого роутера (45 успешно опрошенных серверов): минимум 373 мс, максимум 4833 мс (почти таймаут), среднее около 1050 мс. Из первых ~80 опрошенных серверов пула 36 оказались мёртвыми — почти половина каждый раз съедает полный 5-секундный таймаут впустую, и именно это, а не сама проверка живых серверов, определяет реальную скорость полного обхода пула.
Xray хранит эти данные только в оперативной памяти процесса — при любом рестарте таблица обнуляется полностью, независимо от причины рестарта.
Как выбирается лучший сервер
Два сигнала выше объединяются в 4-уровневый рейтинг (лучший — первый):
Свежий пинг прошёл и Observatory говорит “жив” — оба сигнала согласны. Сортировка по реальной задержке из Observatory, не по пингу.
Свежий пинг прошёл, но Observatory ещё не успел добраться до этого тега в текущем цикле — достижим, но не подтверждён ни в одну сторону.
Свежий пинг прошёл, но Observatory говорит “мёртв” — реальный отрицательный вердикт важнее отсутствия вердикта, поэтому этот уровень ниже предыдущего.
Свежий пинг вообще не прошёл — последний резерв.
Если данных нет вообще ни по одному сигналу — берётся первый сервер из пула профиля. Пинг здесь — не по всей подписке, а только по серверам, перечисленным именно в этом профиле (обычно десятки, не сотни тегов) — недорого делать при каждом пересчёте.
Проблема: Xray забывает результаты проверки на каждом рестарте
Конфиг маршрутизации перезапускает Xray каждый раз, когда реально меняется лучший сервер хотя бы одного профиля — а при свежем пинге кандидаты часто идут “ноздря в ноздрю”, так что выбор реально флипается между близкими соседями каждые несколько минут. Поскольку Observatory живёт только в памяти процесса, любой такой рестарт обнуляет весь накопленный прогресс проверки целиком — не только для изменившейся части пула, а вообще для всех серверов. При этом сам Xray всегда идёт по списку строго по алфавиту тега — никакого понятия “приоритет” или “продолжить с прерванного места” в нём нет вообще, это жёстко зашито в коде.
Итог при наивном подходе (проверять сразу всю подписку целиком): на реальном тесте прогресс застревал в районе 60-80 из 130 серверов и не двигался дальше — каждый рестарт откатывал Xray к тем же самым алфавитно-первым узлам, а до второй половины списка проверка просто никогда не успевала дойти.
Наш хак: приоритетная очередь на основе “протухания”
Раз Xray нельзя попросить продолжить с прерванного места или расставить приоритеты — вместо этого мы на каждом пересчёте формируем список проверки заново, и он всегда маленький:
Собираем объединение серверов из всех профилей — весь пул реальных кандидатов, не только сегодняшнего “победителя”.
Для каждого тега смотрим персистентный файл состояния (копию данных Observatory, которую отдельный фоновый процесс каждые 20 секунд пишет на диск) — тег считается протухшим, если записи нет вообще, или она старше настраиваемого периода (по умолчанию 20 минут).
Если среди тегов профилей есть хоть один протухший — в очередь на проверку идут только протухшие теги профилей. Список маленький (обычно десятки), Xray успевает пройти его целиком за один цикл.
Если все профильные теги свежие — в очередь идут протухшие теги из остальной части подписки (серверы, не используемые ни в одном профиле).
Если протухших нет вообще нигде — очередь пустая, фоновый цикл проверки просто не запускается, пока не появится что-то протухшее.
Ключевая деталь: этот механизм не нуждается в отдельном флаге “проверка в процессе / завершена”. Время последней успешной проверки в персистентном файле — уже единственный источник истины о том, что сделано, а что нет, и оно переживает рестарт Xray (в отличие от внутренней памяти самого процесса). Тег выпадает из очереди ровно в момент, когда его свежо перепроверили, и не возвращается, пока не протухнет снова — значит, повторно просканировать уже сделанную работу физически невозможно, а если рестарт прервал текущий проход на середине — теряется максимум одна проба “в полёте”, а не весь накопленный прогресс.
Живой пример с реального роутера: на первом пересчёте среди тегов профилей нашлось 5 протухших, они были свежо проверены за секунды. На следующем пересчёте (~40 секунд спустя) очередь автоматически переключилась на “остальную подписку” — список из 40 других тегов, ни один из которых не совпал с первыми пятью. Это подтверждает, что приоритет и переключение тиров реально работают, а не совпадение.
Как измеряется скорость: весь пул подписки vs пул профиля
У нас есть три разных сценария измерения задержки, каждый со своим масштабом:
Весь пул подписки — периодический фоновый обход всех серверов подписки целиком (десятки-сотни тегов), результат — общий кеш задержек, который читает UI на вкладке со списком серверов.
Один сервер / одна подписка по кнопке в UI — то же измерение, но по требованию, ограниченное конкретной подпиской или сервером, без ожидания полного обхода.
Пул конкретного профиля — при каждом пересчёте маршрутизации (см. выше) измеряется только пул серверов, реально указанных в этом профиле — на порядок дешевле, чем весь пул подписки, и именно эти данные использует балансировщик при выборе “кто сейчас лучший”.
Все три пишут в один и тот же общий кеш задержек — UI не зависит от того, какая именно из трёх проверок последней его обновила.
Как обновляется подписка, не теряя настройки профилей
При импорте каждая строка подписки превращается в уникальный тег сервера, где уникальность гарантирует хеш всей строки целиком (адрес, секрет, порт, все параметры) — не читаемое имя, оно там только для удобства чтения конфига глазами. Проблема в том, что любое изменение хотя бы одного параметра у провайдера подписки (например, ротация технических параметров камуфляжа) на следующем обновлении даст другой тег для того же самого, с точки зрения провайдера, узла — без специальной обработки это молча рвёт связь: профиль, у которого сервер был отмечен, просто перестаёт его “видеть”.
Решение — отдельный ключ сопоставления match_key = host + port + секрет + камуфляж-домен, который считается при каждом импорте и хранится рядом с тегом. При обновлении подписки для каждого профиля:
Тег нашёлся по
match_keyсреди новых записей → тихо заменяется на новый, без следа — это тот же узел, просто сменивший параметры.Тег не нашёлся → удаляется из активного пула профиля, но не молча: попадает в список “пропавшие серверы” (имя + время), UI подсвечивает это предупреждением под профилем.
Список пропавших серверов очищается автоматически, как только профиль в следующий раз явно сохраняют через UI.
Kill-Switch: без окна уязвимости
Защита от утечки трафика в обход правил состоит из двух независимых слоёв:
“Мягкий” слой (всегда включён, бесплатно). Это не отдельный механизм, а побочное свойство того, как вообще работает перехват трафика (см. следующий раздел): пакет всегда перенаправляется на локальный адрес процесса Xray. Если процесс не запущен — пакету просто некуда доставиться, соединение рвётся локально, а не тихо уходит напрямую в обход правил.
“Жёсткий” слой (включается на профиль). Четыре звена цепочки:
Клиент резолвит домен из списка профиля через DNS этого роутера.
DNS-сервер по правилу наполняет отдельный набор адресов (ipset) резолвнутыми IP.
Отдельное правило firewall создаёт этот набор адресов как нативный набор firewall — без него DNS-серверу просто некуда писать.
Правило firewall блокирует весь LAN→WAN трафик, чей адрес назначения попадает в этот набор.
Пункт 3 — не теория, а реальный найденный баг: правило блокировки годами существовало и выглядело взведённым, но отдельное правило, которое должно было создать сам набор адресов, отсутствовало — так что жёсткий слой на чистом OpenWrt не блокировал вообще ничего. Подтверждено вживую полным циклом включения/выключения на реальном роутере. Заодно нашёлся баг по соседству: перезагрузка firewall не подчищает осиротевший набор при отключении защиты — адреса, оставшиеся от уже отключённого профиля, утекали в правило совсем другого профиля при следующем включении. Оба исправлены.
DNS-сервер умеет реагировать только на буквальные доменные имена — не на скомпилированный бинарный список категорий, которым пользуется сам Xray. Поэтому для профилей на готовых категориях доменов нужен отдельный шаг: взять исходный текстовый список категории (не бинарный файл) и скормить его DNS-серверу тем же механизмом, что и собственные списки. Часть записей в исходных списках — не конкретные домены, а правила сопоставления по шаблону (“любой домен, содержащий подстроку X”) — таких доменов бесконечно много, и большинство из них ещё не существует. Xray сопоставляет их в моменте по живому трафику; DNS-сервер может реагировать только на реальный запрос к конкретному, заранее известному имени — повторить это через “резолвь → добавь IP в список” нельзя в принципе. Это архитектурный потолок покрытия, а не недоработка.
Важная деталь — правило firewall не опрашивает раз в минуту “жив ли Xray прямо сейчас” (более ранняя версия делала именно так, оставляя окно уязвимости до 60 секунд). Правило взводится сразу при включении защиты и остаётся взведённым постоянно: собственный механизм перехвата трафика (см. ниже) направляет трафик на локальный адрес процесса Xray через специальную форму NAT, которая физически проходит через другую цепочку firewall, чем правило kill-switch. Значит правило kill-switch никогда не видит корректно перенаправленный трафик — оно инертно в штатном режиме и срабатывает только в том единственном случае, для которого и существует: если сам процесс или его правила перехвата пропали. Постоянное взведение без опроса — проще предыдущей версии и не оставляет временного зазора.

Перехват LAN-трафика: фундамент, на котором всё стоит
Без перехвата трафика ни один профиль и ни один kill-switch вообще не видит реальные пакеты устройств локальной сети — это включает сам механизм, через который наш модуль вообще может что-то маршрутизировать.
xkeen поставляется со своим встроенным механизмом захвата трафика по портам — но он пишет правила через legacy iptables, а это совершенно отдельный набор правил от того, который ядро реально использует для форвардинга пакетов на современном OpenWrt (nftables по умолчанию). Подтверждено на реальном железе: встроенный механизм рапортует успех и ничего не делает — трафик реальных устройств идёт как обычный форвардинг, будто перехвата вообще нет. На “родной” платформе xkeen этого расхождения нет — но на чистом OpenWrt нужен свой механизм, который и есть отдельная nftables-цепочка нашего модуля.
Управляемый файл правил перезаписывается целиком при каждом изменении настроек — это гарантирует, что конфигурация на диске никогда не разойдётся с тем, что реально включено:
chain sr_smartroute_redirect { type nat hook prerouting priority dstnat; policy accept; iifname != "br-lan" return ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8 } return tcp dport { 80,443 } redirect to :61219 }
Три момента:
Правило применяется только к пакетам, реально пришедшим от LAN-устройств — трафик в обратную сторону или сам роутер не трогаются.
Приватные адреса назначения явно исключены — перенаправление трафика внутри локальной сети на прокси было бы просто поломкой без причины.
Порт назначения — локальный вход Xray, настроенный на определение цели соединения по самому пакету (TLS SNI / HTTP Host / QUIC) — решение о маршрутизации не зависит от того, прошёл ли DNS-запрос через этот же роутер.
Перед применением сгенерированный файл правил обязательно проверяется встроенной командой синтаксической проверки firewall — если проверка не прошла, файл удаляется и функция возвращает ошибку, не трогая уже работающее состояние.

Защита от утечек: DNS, IPv6, QUIC
Перехват по портам 80/443 закрывает основной случай, но не все пути, которыми трафик может уйти в обход правил маршрутизации. Три дополнительных переключателя закрывают конкретные пробелы.
Защита DNS. Принудительно заворачивает все DNS-запросы (порт 53) с локальной сети на сам роутер — даже если устройство явно прописано на сторонний DNS-сервер:
tcp dport 53 redirect to :53 udp dport 53 redirect to :53
Без этого правила запросы могут уйти напрямую мимо туннеля, раскрывая, какие домены резолвит устройство, ещё до начала самого соединения — а жёсткий kill-switch вообще не увидит домены для блокировки, поскольку его механизм целиком завязан на то, что DNS-запросы проходят именно через этот роутер.
Защита от утечки по IPv6. Наш перехват работает только для IPv4 — сайт, доступный по IPv6, мог бы открыться напрямую в обход туннеля и всех правил, классическая слепая зона. Решение простое и надёжное — не давать LAN→WAN трафику по IPv6 форвардиться вообще:
chain sr_smartroute_block_ipv6 { type filter hook forward priority -1; policy accept; iifname "br-lan" meta nfproto ipv6 counter drop }
Такие соединения просто падают закрыто и уходят через IPv4 (уже защищённый) вместо утечки.
Защита от утечки через QUIC/HTTP3. Перехват по портам ловит только TCP-трафик. Сайты, объявляющие поддержку HTTP/3 (заголовок Alt-Svc: h3), браузер может открыть по протоколу QUIC — тот же номер порта 443, но по UDP, который полностью проходит мимо TCP-перехвата. Подтверждено на практике: ресурсы с включённым h3 ни разу не попадали в лог Xray, несмотря на нормальную загрузку в браузере — трафик уходил напрямую по UDP/443, раскрывая реальный адрес устройства.
chain sr_smartroute_block_quic { type filter hook forward priority -1; policy accept; iifname "br-lan" udp dport { 80,443 } counter drop }
Блокируется именно исходящий UDP на тех же портах, что и TCP-перехват — не попытка завернуть UDP в туннель (для чего локальный вход Xray не предназначен). Браузеры при этом просто откатываются на обычный TCP/TLS без потери функциональности — пользователь ничего не замечает, кроме того, что утечки больше нет.

Заключение
Точечная маршрутизация с автобалансировкой — это, по сути, задача на постоянный мониторинг: нужно непрерывно знать, какие узлы пула живы прямо сейчас, и уметь быстро переключаться между ними без участия человека. Самая нетривиальная часть — не сама идея, а то, как обойти ограничения конкретного движка (в нашем случае — Xray-core), у которого готовый балансировщик оказался сломан, а данные о живости серверов не переживают рестарт процесса.
Проект открытый, ставится одной командой, работает и на OpenWrt, и на KeenticOS:
https://github.com/LackyCraft/xkeen-smartroute
Более глубокие технические разборы каждой отдельной темы (собственный балансировщик, генерация правил маршрутизации, устройство панели, парсинг подписки) — в docs/functionality_doc/ репозитория.

