Многие сервисы сегодня выдают клиенту не один конкретный конфиг для подключения, а 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

Интерфейс Xkeen SmartRoute
Интерфейс Xkeen SmartRoute

SmartRoute — модуль поверх xkeen, добавляющий слой управления маршрутизацией через веб-интерфейс (LuCI-модуль + отдельная живая панель). Идея в том, чтобы полностью автоматизировать всё, что происходит между «вот моя подписка» и «трафик реально пошёл по нужным серверам»: от пользователя требуется только вставить ссылку на подписку и создать правила — например, «сайт1 → через сервер/группу A», «сеть2 → через сервер B» — а дальше эти правила просто начинают работать сами. Всю техническую часть — импорт и парсинг подписки, генерацию и валидацию конфигов, перезапуск и мониторинг живости Xray, выбор лучшего сервера в группе — берёт на себя SmartRoute, вручную редактировать конфиги xkeen/Xray не нужно. Кратко, что внутри:

  • Профили маршрутизации — связка “домены (по категории или свой список) и/или устройства (IP/CIDR) и/или IP-диапазоны” → один конкретный сервер или группа с автовыбором самого быстрого живого.

  • Собственный алгоритм выбора сервера в группе — не встроенный leastPing Xray (он сломан в используемой версии ядра, подробности ниже).

  • 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 → трафик
  1. Подписка импортируется и парсится в список серверов (servers.json).

  2. В UI вы создаёте профили: что маршрутизировать (домены/устройства/IP) и куда (конкретный сервер или группа с автовыбором).

  3. Генератор конфига (genroute.sh) превращает каждый профиль в правило routing.rules для Xray, выбирая для групп-балансеров лучший сервер прямо сейчас.

  4. Xray перезапускается только если что-то реально изменилось, и начинает применять новые правила к трафику.

Установка одной командой: пошагово

Скрипт полностью автоматический и идемпотентный — его можно запускать повторно для обновления. Внутри шесть последовательных шагов, каждый пишет прогресс в консоль:

Шаг 1/6 — Entware. Проверяется, установлен ли пакетный менеджер Entware (opkg); если нет — ставится с нуля вместе с хуком автозапуска, чтобы весь стек переживал перезагрузку роутера.

шаг 1, установка Entware
шаг 1, установка Entware

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

шаг 2, установка xkeen
шаг 2, установка xkeen

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

шаг 3, ввод пароля xkeen-UI
шаг 3, ввод пароля xkeen-UI

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

шаг 4, установка SmartRoute
шаг 4, установка SmartRoute

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

шаг 5, установка панели и пароль
шаг 5, установка панели и пароль

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

шаг 6, финальный вывод с адресами интерфейсов
шаг 6, финальный вывод с адресами интерфейсов

По завершении скрипт печатает адреса всех интерфейсов:

LuCI:      http://<IP-роутера>/  ->  Services / Сервисы -> XKeen SmartRoute
xkeen-UI:  http://<IP-роутера>:1000/
Панель:    http://<IP-роутера>:1001/

Дальше: Подписки → вставить ссылку на VLESS/Trojan-подписку → «Импортировать», Профили → создать первое правило маршрутизации, Kill-Switch → включить защиту для профиля.

главная страница панели SmartRoute после установки
главная страница панели SmartRoute после установки

Технические моменты

Маршрутизация по доменам и проблема трафика без 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-уровневый рейтинг (лучший — первый):

  1. Свежий пинг прошёл и Observatory говорит “жив” — оба сигнала согласны. Сортировка по реальной задержке из Observatory, не по пингу.

  2. Свежий пинг прошёл, но Observatory ещё не успел добраться до этого тега в текущем цикле — достижим, но не подтверждён ни в одну сторону.

  3. Свежий пинг прошёл, но Observatory говорит “мёртв” — реальный отрицательный вердикт важнее отсутствия вердикта, поэтому этот уровень ниже предыдущего.

  4. Свежий пинг вообще не прошёл — последний резерв.

Если данных нет вообще ни по одному сигналу — берётся первый сервер из пула профиля. Пинг здесь — не по всей подписке, а только по серверам, перечисленным именно в этом профиле (обычно десятки, не сотни тегов) — недорого делать при каждом пересчёте.

Проблема: Xray забывает результаты проверки на каждом рестарте

Конфиг маршрутизации перезапускает Xray каждый раз, когда реально меняется лучший сервер хотя бы одного профиля — а при свежем пинге кандидаты часто идут “ноздря в ноздрю”, так что выбор реально флипается между близкими соседями каждые несколько минут. Поскольку Observatory живёт только в памяти процесса, любой такой рестарт обнуляет весь накопленный прогресс проверки целиком — не только для изменившейся части пула, а вообще для всех серверов. При этом сам Xray всегда идёт по списку строго по алфавиту тега — никакого понятия “приоритет” или “продолжить с прерванного места” в нём нет вообще, это жёстко зашито в коде.

Итог при наивном подходе (проверять сразу всю подписку целиком): на реальном тесте прогресс застревал в районе 60-80 из 130 серверов и не двигался дальше — каждый рестарт откатывал Xray к тем же самым алфавитно-первым узлам, а до второй половины списка проверка просто никогда не успевала дойти.

Наш хак: приоритетная очередь на основе “протухания”

Раз Xray нельзя попросить продолжить с прерванного места или расставить приоритеты — вместо этого мы на каждом пересчёте формируем список проверки заново, и он всегда маленький:

  1. Собираем объединение серверов из всех профилей — весь пул реальных кандидатов, не только сегодняшнего “победителя”.

  2. Для каждого тега смотрим персистентный файл состояния (копию данных Observatory, которую отдельный фоновый процесс каждые 20 секунд пишет на диск) — тег считается протухшим, если записи нет вообще, или она старше настраиваемого периода (по умолчанию 20 минут).

  3. Если среди тегов профилей есть хоть один протухший — в очередь на проверку идут только протухшие теги профилей. Список маленький (обычно десятки), Xray успевает пройти его целиком за один цикл.

  4. Если все профильные теги свежие — в очередь идут протухшие теги из остальной части подписки (серверы, не используемые ни в одном профиле).

  5. Если протухших нет вообще нигде — очередь пустая, фоновый цикл проверки просто не запускается, пока не появится что-то протухшее.

Ключевая деталь: этот механизм не нуждается в отдельном флаге “проверка в процессе / завершена”. Время последней успешной проверки в персистентном файле — уже единственный источник истины о том, что сделано, а что нет, и оно переживает рестарт Xray (в отличие от внутренней памяти самого процесса). Тег выпадает из очереди ровно в момент, когда его свежо перепроверили, и не возвращается, пока не протухнет снова — значит, повторно просканировать уже сделанную работу физически невозможно, а если рестарт прервал текущий проход на середине — теряется максимум одна проба “в полёте”, а не весь накопленный прогресс.

Живой пример с реального роутера: на первом пересчёте среди тегов профилей нашлось 5 протухших, они были свежо проверены за секунды. На следующем пересчёте (~40 секунд спустя) очередь автоматически переключилась на “остальную подписку” — список из 40 других тегов, ни один из которых не совпал с первыми пятью. Это подтверждает, что приоритет и переключение тиров реально работают, а не совпадение.

Как измеряется скорость: весь пул подписки vs пул профиля

У нас есть три разных сценария измерения задержки, каждый со своим масштабом:

  • Весь пул подписки — периодический фоновый обход всех серверов подписки целиком (десятки-сотни тегов), результат — общий кеш задержек, который читает UI на вкладке со списком серверов.

  • Один сервер / одна подписка по кнопке в UI — то же измерение, но по требованию, ограниченное конкретной подпиской или сервером, без ожидания полного обхода.

  • Пул конкретного профиля — при каждом пересчёте маршрутизации (см. выше) измеряется только пул серверов, реально указанных в этом профиле — на порядок дешевле, чем весь пул подписки, и именно эти данные использует балансировщик при выборе “кто сейчас лучший”.

Все три пишут в один и тот же общий кеш задержек — UI не зависит от того, какая именно из трёх проверок последней его обновила.

Как обновляется подписка, не теряя настройки профилей

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

Решение — отдельный ключ сопоставления match_key = host + port + секрет + камуфляж-домен, который считается при каждом импорте и хранится рядом с тегом. При обновлении подписки для каждого профиля:

  • Тег нашёлся по match_key среди новых записей → тихо заменяется на новый, без следа — это тот же узел, просто сменивший параметры.

  • Тег не нашёлся → удаляется из активного пула профиля, но не молча: попадает в список “пропавшие серверы” (имя + время), UI подсвечивает это предупреждением под профилем.

Список пропавших серверов очищается автоматически, как только профиль в следующий раз явно сохраняют через UI.

Kill-Switch: без окна уязвимости

Защита от утечки трафика в обход правил состоит из двух независимых слоёв:

“Мягкий” слой (всегда включён, бесплатно). Это не отдельный механизм, а побочное свойство того, как вообще работает перехват трафика (см. следующий раздел): пакет всегда перенаправляется на локальный адрес процесса Xray. Если процесс не запущен — пакету просто некуда доставиться, соединение рвётся локально, а не тихо уходит напрямую в обход правил.

“Жёсткий” слой (включается на профиль). Четыре звена цепочки:

  1. Клиент резолвит домен из списка профиля через DNS этого роутера.

  2. DNS-сервер по правилу наполняет отдельный набор адресов (ipset) резолвнутыми IP.

  3. Отдельное правило firewall создаёт этот набор адресов как нативный набор firewall — без него DNS-серверу просто некуда писать.

  4. Правило firewall блокирует весь LAN→WAN трафик, чей адрес назначения попадает в этот набор.

Пункт 3 — не теория, а реальный найденный баг: правило блокировки годами существовало и выглядело взведённым, но отдельное правило, которое должно было создать сам набор адресов, отсутствовало — так что жёсткий слой на чистом OpenWrt не блокировал вообще ничего. Подтверждено вживую полным циклом включения/выключения на реальном роутере. Заодно нашёлся баг по соседству: перезагрузка firewall не подчищает осиротевший набор при отключении защиты — адреса, оставшиеся от уже отключённого профиля, утекали в правило совсем другого профиля при следующем включении. Оба исправлены.

DNS-сервер умеет реагировать только на буквальные доменные имена — не на скомпилированный бинарный список категорий, которым пользуется сам Xray. Поэтому для профилей на готовых категориях доменов нужен отдельный шаг: взять исходный текстовый список категории (не бинарный файл) и скормить его DNS-серверу тем же механизмом, что и собственные списки. Часть записей в исходных списках — не конкретные домены, а правила сопоставления по шаблону (“любой домен, содержащий подстроку X”) — таких доменов бесконечно много, и большинство из них ещё не существует. Xray сопоставляет их в моменте по живому трафику; DNS-сервер может реагировать только на реальный запрос к конкретному, заранее известному имени — повторить это через “резолвь → добавь IP в список” нельзя в принципе. Это архитектурный потолок покрытия, а не недоработка.

Важная деталь — правило firewall не опрашивает раз в минуту “жив ли Xray прямо сейчас” (более ранняя версия делала именно так, оставляя окно уязвимости до 60 секунд). Правило взводится сразу при включении защиты и остаётся взведённым постоянно: собственный механизм перехвата трафика (см. ниже) направляет трафик на локальный адрес процесса Xray через специальную форму NAT, которая физически проходит через другую цепочку firewall, чем правило kill-switch. Значит правило kill-switch никогда не видит корректно перенаправленный трафик — оно инертно в штатном режиме и срабатывает только в том единственном случае, для которого и существует: если сам процесс или его правила перехвата пропали. Постоянное взведение без опроса — проще предыдущей версии и не оставляет временного зазора.

вкладка 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 — если проверка не прошла, файл удаляется и функция возвращает ошибку, не трогая уже работающее состояние.

вкладка «Защита от утечек», секция «Перехват LAN-трафика», порты 80,443
вкладка «Защита от утечек», секция «Перехват LAN-трафика», порты 80,443

Защита от утечек: 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/ репозитория.