У меня дома около десяти устройств: ноутбук, телефон, телевизоры, колонки. На каждом — свои приложения, которым нужен доступ к заблокированным ресурсам. Ставить VPN-клиент на каждое устройство — мучение: телевизор вообще не умеет в VLESS, колонка — тем более. Хотелось одного: настроить VPN один раз на роутере — и чтобы все устройства в сети получили доступ автоматически, без установки чего-либо.
Спустя пару вечеров ковыряния в OpenWrt, nftables и Xray у меня получилась система, которая:
Прозрачно проксирует TCP-трафик всех устройств в сети через VLESS+Reality
Автоматически определяет, какой трафик гнать через VPN, а какой — напрямую (сплит-роутинг по GeoIP и доменам)
Шифрует DNS через AdGuard Home + DoH, попутно фильтруя рекламу и трекеры
Обновляет список серверов из подписки каждые 30 минут
Балансирует нагрузку между серверами, выбирая самый быстрый
Автоматически восстанавливается после сбоев (procd + watchdog + hotplug)
Работает стабильно неделями без перезагрузки
В этой статье — полный путь от коробочного роутера до работающей системы. С объяснениями не только «как», но и «почему». С багами, которые я нашёл в собственном конфиге. С конфигами, которые можно взять и адаптировать.
Почему VPN на роутере, а не на устройствах
Прежде чем лезть в конфиги — давайте разберёмся, зачем это вообще нужно.
VPN-клиент на устройстве решает задачу для одного девайса. У этого подхода есть проблемы, которые проявляются, когда устройств больше двух-трёх:
Smart TV, игровые консоли, IoT-устройства не поддерживают кастомные VPN-протоколы. На телевизоре Samsung нет клиента для VLESS.
На каждом устройстве нужно устанавливать, настраивать и обновлять клиент. Жена/ребёнок/гость не будут разбираться с v2rayNG.
Клиент может отключиться, и пользователь не заметит. Трафик пойдёт напрямую.
На мобильных устройствах VPN-клиент жрёт батарею (постоянный туннель, обработка пакетов в userspace).
VPN на роутере решает все эти проблемы разом: любое устройство, подключённое к Wi-Fi или по кабелю, автоматически получает проксирование. Настроить нужно один раз, обновить — в одном месте. Устройства вообще не знают, что трафик проксируется — для них это обычный интернет.
Цена — более сложная настройка. Но настроить один раз роутер проще, чем поддерживать VPN на пятнадцати устройствах.
Выбор железа
Не каждый роутер подойдёт. Нужны три вещи:
Поддержка OpenWrt — без неё ничего не получится. Проверяйте на openwrt.org/toh.
Достаточно RAM — Xray-core написан на Go и потребляет ~100 МБ физической памяти. Плюс geodata (~85 МБ в tmpfs). Роутеры с 128 МБ RAM не потянут. Минимум — 256 МБ, комфортно — 512 МБ.
Нормальный CPU — ARM Cortex-A53 или мощнее. MIPS-роутеры с 580 МГц будут задыхаться на шифровании.
Я выбрал Cudy TR3000 v1 (~4500-5000 ₽ на момент покупки):
SoC: MediaTek Filogic (MT7981B), ARM Cortex-A53, 2 ядра
RAM: 496 МБ DDR4
Flash: 128 МБ NAND (overlay ~44 МБ)
Порты: 1× WAN 2.5GbE, 3× LAN 1GbE
Wi-Fi: Wi-Fi 6 (2.4 ГГц + 5 ГГц)
За свои деньги — отличное железо. Полгигабайта RAM хватает с запасом, два ядра A53 справляются с шифрованием без заметных тормозов, а 2.5GbE WAN пригодится, когда провайдер предложит тариф побыстрее.
Подводный камень с ревизиями Flash
Если покупаете Cudy TR3000 v1 — обратите внимание на серийный номер. Роутеры с серийниками от 2544 и выше (примерно с ноября 2025) идут с новым чипом NAND Flash — ESMT F50L1G41LC. Старые образы OpenWrt на них не загружаются.
Поддержка нового Flash появилась только в OpenWrt 24.10.5+. Для прошивки нужна специальная intermediate firmware от Cudy (ZIP-архив с датой 20251118 на странице загрузок), а уже потом — sysupgrade на нужную версию OpenWrt. Без промежуточной прошивки роутер уйдёт в кирпич.
Прошивка OpenWrt
Прошивка идёт в два этапа.
Этап 1: Из стоковой прошивки Cudy заходим в веб-интерфейс → «Firmware Upgrade» → загружаем bin-файл из intermediate firmware ZIP.
Этап 2: После перезагрузки появляется LuCI от intermediate OpenWrt. Через него делаем sysupgrade на финальную версию OpenWrt (в моём случае — 25.12.2).
После прошивки:
ssh root@192.168.1.1
Прошивка: OpenWrt 25.12.2 (ядро Linux 6.12.74), платформа mediatek/filogic, архитектура aarch64_cortex-a53.
Важно: начиная с OpenWrt ~25.x пакетный менеджер — apk, а не opkg. Команды установки отличаются:
apk update apk add xray-core kmod-nft-tproxy kmod-nf-tproxy nftables-json curl
Архитектура решения
Прежде чем настраивать — поймём, что мы строим. Вот общая схема:
Устройство в сети (ноутбук, телефон, ТВ) │ ├─ TCP к blocked-site.com:443 │ ▼ │ Роутер: nftables (TPROXY) ──► Xray (порт 12345) │ перехватывает TCP │ │ с интерфейса br-lan ├─ sniffing → routing │ ├─ .ru / geoip:ru → direct │ └─ остальное → VLESS+Reality │ └─ DNS-запрос (UDP 53) ▼ AdGuard Home (порт 53, фильтрация рекламы) │ ▼ DoH (https://1.1.1.1/dns-query) ──► HTTPS наружу │ ▼ nftables (TPROXY) ──► Xray ──► VPN-туннель
Ключевой момент: ни один plaintext DNS-запрос не покидает роутер. AdGuard Home принимает DNS от устройств, резолвит через DNS-over-HTTPS, а HTTPS-трафик к DNS-серверам проходит через TPROXY и уходит через VPN.
Почему именно эта связка
VLESS + Reality + XTLS-Vision — три технологии, работающие вместе:
VLESS — легковесный прокси-протокол. Наследник VMess из экосистемы V2Ray, но без собственного шифрования: шифрование полностью делегируется TLS. Меньше оверхед, проще протокол.
Reality — технология маскировки TLS-хендшейка. В отличие от обычного TLS, не требует реального домена и сертификата. Сервер предъявляет валидный TLS-сертификат чужого сайта (SNI), а клиент аутентифицируется через пару ключей (publicKey/shortId). Для систем DPI это выглядит как обычное TLS-соединение к легитимному ресурсу. Заблокировать такое — значит заблокировать все TLS-соединения к этому SNI, что затронет легитимных пользователей.
XTLS-Vision — оптимизация, при которой Xray «проваливает» внутренний TLS прямо во внешний TLS без двойного шифрования. Когда вы подключаетесь к HTTPS-сайту через VLESS+Reality, данные шифруются один раз (а не два, как при классическом прокси поверх TLS). Результат — почти нативная скорость.
Транспорт — чистый TCP (не WebSocket, не gRPC). TCP даёт минимальную задержку и не добавляет лишних заголовков.
Почему TPROXY, а не REDIRECT
Есть два способа перехватить трафик на роутере для проксирования:
REDIRECT (DNAT) — подменяет адрес назначения на 127.0.0.1:порт. Проблема: оригинальный адрес теряется. Прокси должен восстановить его из заголовков протокола (HTTP Host, TLS SNI). Для чистого TCP без HTTP/TLS — невозможно.
TPROXY (Transparent Proxy) — передаёт пакет приложению с сохранением оригинального IP назначения. Xray видит реальный адрес, куда клиент хотел подключиться. Это критично для прозрачного проксирования: Xray должен знать destination, чтобы правильно маршрутизировать запрос.
TPROXY сложнее в настройке (нужны модули ядра, policy routing, специальные правила nftables), но он надёжнее и работает с любым TCP-трафиком.
Настройка сети: PPPoE и топология
Моя топология:
Интернет (провайдер, GPON) │ ▼ Оптический терминал (режим моста / bridge) │ PPPoE passthrough ▼ Cudy TR3000 (OpenWrt) ── PPPoE клиент │ ├── WAN (eth0) → pppoe-wan (MTU 1480) ├── LAN (eth1) → br-lan (192.168.1.0/24) ├── Wi-Fi 2.4 ГГц → br-lan └── Wi-Fi 5 ГГц → br-lan
Провайдерский терминал переведён в режим моста — PPPoE-сессию поднимает сам Cudy. Это важно: мы хотим, чтобы роутер был первым хопом и полностью контролировал трафик.
MTU = 1480 — стандарт для PPPoE (1500 − 8 байт PPP − 12 байт PPPoE). В nftables OpenWrt автоматически настраивает MSS clamping для корректной работы TCP.
DNS провайдера, полученные по PPPoE, не используются — вместо них Cloudflare (1.1.1.1) и Google (8.8.8.8).
Установка Xray и необходимых пакетов
apk update apk add xray-core \ kmod-nf-tproxy \ kmod-nft-tproxy \ kmod-nft-core \ kmod-nft-nat \ kmod-nft-fib \ nftables-json \ curl
Что зачем:
xray-core— собственно Xray (VLESS, Reality, маршрутизация, Observatory)kmod-nf-tproxy+kmod-nft-tproxy— модули ядра для TPROXY в netfilter/nftableskmod-nft-fib— FIB lookup, нужен для policy routing в nftablesnftables-json— утилитаnftс поддержкой JSONcurl— для скачивания geodata и подписки
Конфиг Xray: разбор по частям
Полный конфиг состоит из нескольких секций. Разберём каждую.
Входящее соединение (Inbound)
{ "tag": "tproxy-in", "port": 12345, "protocol": "dokodemo-door", "settings": { "network": "tcp", "followRedirect": true }, "sniffing": { "enabled": true, "destOverride": ["http", "tls"], "routeOnly": true }, "streamSettings": { "sockopt": { "tproxy": "tproxy" } } }
Разберём построчно:
dokodemo-door — «дверь куда угодно». Специальный протокол Xray для приёма перенаправленного трафика. Он не требует от клиента знать о прокси — трафик приходит прозрачно через TPROXY.
followRedirect: true — использовать оригинальный адрес назначения из пакета (восстановленный TPROXY). Без этого Xray не будет знать, куда переправлять соединение.
sniffing с destOverride: ["http", "tls"] — критически важная настройка. Xray анализирует первые байты соединения: для TLS извлекает домен из SNI (Server Name Indication), для HTTP — из заголовка Host. Извлечённый домен используется для маршрутизации по доменам (geosite), а не только по IP (geoip).
routeOnly: true — ключевой нюанс. Без этого флага sniffing не только извлекает домен, но и подменяет адрес назначения в самом соединении. Это ломает некоторые сценарии: например, если сервер ожидает подключение по IP, а Xray подставляет домен. С routeOnly: true sniffing извлекает домен только для маршрутизации, а соединение устанавливается по оригинальному адресу. Проще говоря: домен используется чтобы решить «через proxy или direct», но дальше пакет идёт как есть.
Без sniffing Xray видит только IP-адрес. С ним — видит youtube.com, gosuslugi.ru, discord.com и может принимать решения на уровне доменов.
tproxy: "tproxy" — режим TPROXY (а не redirect). Сохраняет оригинальный IP назначения.
Исходящие соединения (Outbounds)
Три типа:
1. Прокси-серверы (VLESS+Reality):
{ "tag": "proxy-1", "protocol": "vless", "settings": { "vnext": [{ "address": "your-server.example.com", "port": 443, "users": [{ "id": "ваш-uuid", "flow": "xtls-rprx-vision", "encryption": "none" }] }] }, "streamSettings": { "network": "tcp", "security": "reality", "realitySettings": { "serverName": "sni-domain.example.com", "publicKey": "ваш-publicKey", "fingerprint": "chrome", "shortId": "1234" } } }
Ключевые параметры:
address/port— адрес и порт VPN-сервераid— UUID для аутентификацииflow: "xtls-rprx-vision"— включает XTLS-Vision (нулевое копирование для TLS-трафика)serverName— SNI, который сервер будет предъявлять при TLS-хендшейкеpublicKey/shortId— пара для Reality-аутентификации (получаете от провайдера VPN или генерируете при настройке своего сервера)fingerprint: "chrome"— Xray имитирует TLS-фингерпринт Chrome, чтобы DPI не отличил его от настоящего браузера
Серверов может быть несколько. Я использую пул из 5–10 серверов для балансировки.
2. Direct — прямое соединение:
{"tag": "direct", "protocol": "freedom"}
Трафик идёт напрямую, минуя прокси. Для российских сайтов, локальных ресурсов.
3. Block — чёрная дыра:
{"tag": "block", "protocol": "blackhole"}
Дроп трафика. Можно использовать для блокировки рекламы, телеметрии и т.д.
Балансировка: Observatory + leastPing
{ "observatory": { "subjectSelector": ["proxy-"], "probeURL": "http://cp.cloudflare.com", "probeInterval": "120s", "enableConcurrency": true } }
Observatory — встроенный мониторинг здоровья серверов. Каждые 120 секунд Xray делает HTTP-запрос к cp.cloudflare.com через каждый сервер с тегом proxy-*. Мёртвые серверы исключаются из пула.
В routing настраиваем балансировщик:
{ "balancers": [{ "tag": "vpn-auto", "selector": ["proxy-"], "strategy": {"type": "leastPing"} }] }
leastPing — выбирает сервер с наименьшей задержкой. Работает совместно с Observatory: из живых серверов берётся самый быстрый.
Нюанс с Reality и Observatory: Go HTTP-клиент, который использует Observatory для проб, не проходит Reality-хендшейк. Если ваши серверы используют SNI равный IP-адресу (без домена), пробы будут падать с ошибкой x509: cannot validate certificate for IP because it doesn't contain any IP SANs. Сервер при этом работает нормально — Xray-клиент проходит Reality-хендшейк корректно, а Observatory — нет. Если у вас есть сервер с валидным доменным SNI, он будет определяться как alive. Остальные — как dead. Балансировка фактически не работает для серверов с IP-based SNI. Это известная особенность, а не баг.
Маршрутизация (Routing) — сплит-роутинг
Вот где происходит магия:
{ "domainStrategy": "IPIfNonMatch", "rules": [ {"type": "field", "ip": ["geoip:private"], "outboundTag": "direct"}, {"type": "field", "ip": ["geoip:ru"], "outboundTag": "direct"}, {"type": "field", "domain": ["geosite:ru-available-only-inside"], "outboundTag": "direct"}, {"type": "field", "domain": ["regexp:.*\\.ru$", "regexp:.*\\.su$", "regexp:.*\\.xn--p1ai$"], "outboundTag": "direct"}, {"type": "field", "network": "tcp", "balancerTag": "vpn-auto"} ] }
Правила применяются сверху вниз, первое совпадение побеждает:
Частные IP (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8) → direct. Локальный трафик не должен уходить в прокси, иначе перестанут работать LuCI, SSH, принтеры, IoT-устройства.
Российские IP (geoip:ru) → direct. Зачем проксировать трафик к Яндексу или ВК? Он и так работает. Проксирование только замедлит.
Сайты, работающие только из РФ (geosite:ru-available-only-inside) → direct. Это кастомная категория из проекта runetfreedom/russia-v2ray-rules-dat: госуслуги, банки, сервисы, которые блокируют зарубежный трафик. Если пустить их через VPN-сервер за рубежом — они перестанут работать.
Домены .ru / .su / .рф → direct. Регулярные выражения ловят все домены в этих зонах.
.xn--p1ai— это punycode-кодировка.рф.Всё остальное TCP → vpn-auto (балансировщик). YouTube, Discord, ChatGPT, Steam, GitHub — всё уходит через VPN.
Если какой-то сервис работает хуже через VPN (например, игровые серверы с чувствительностью к задержке), можно добавить его в direct. Пример для FaceIt:
{"type": "field", "domain": ["domain:faceit.com", "domain:faceitcdn.net"], "outboundTag": "direct"}
Правило добавляется перед финальным balancerTag: vpn-auto — порядок важен, первое совпадение побеждает.
domainStrategy: "IPIfNonMatch" — если ни одно доменное правило не сработало, Xray резолвит домен в IP и пробует сопоставить с IP-правилами. Это позволяет, например, российскому CDN с .com-доменом попасть в direct, если его IP находится в geoip:ru.
DNS: AdGuard Home + DoH
DNS — одно из самых уязвимых мест в любой VPN-конфигурации. Я прошёл через три итерации, прежде чем закрыл все утечки.
Итерация 1: dnsmasq → 1.1.1.1 (plaintext). Стандартная настройка OpenWrt. Проблема: TPROXY перехватывает только TCP, а DNS-запросы — это UDP. UDP к 1.1.1.1:53 уходил напрямую через WAN, провайдер видел все запрашиваемые домены в открытом виде.
Итерация 2: https-dns-proxy. Пакет для OpenWrt, который принимает DNS на localhost и форвардит через DoH. Работало, но это просто прослойка без фильтрации и без удобного управления.
Итерация 3 (финальная): AdGuard Home. Полноценный DNS-сервер с фильтрацией рекламы, встроенным DoH и веб-панелью. Бонусом — блокировка рекламы и трекеров на уровне DNS для всех устройств в сети.
Установка:
# Скачиваем бинарник с GitHub cd /tmp curl -fsSL -o adguardhome.tar.gz \ "https://github.com/AdguardTeam/AdGuardHome/releases/latest/download/AdGuardHome_linux_arm64.tar.gz" tar xzf adguardhome.tar.gz cp AdGuardHome/AdGuardHome /usr/bin/adguardhome chmod +x /usr/bin/adguardhome mkdir -p /etc/adguardhome # Первоначальная настройка через веб-интерфейс # Запустите: adguardhome -w /etc/adguardhome # Откройте http://192.168.1.1:3000 и пройдите визард
Далее — dnsmasq нужно сдвинуть на роль чистого DHCP, чтобы он не занимал порт 53:
# dnsmasq больше не слушает DNS (только DHCP) uci set dhcp.@dnsmasq[0].port='0' uci commit dhcp /etc/init.d/dnsmasq restart
Init-скрипт для AdGuard Home через procd:
#!/bin/sh /etc/rc.common USE_PROCD=1 START=95 STOP=15 start_service() { procd_open_instance procd_set_param command /usr/bin/adguardhome -w /etc/adguardhome --no-check-update procd_set_param respawn 3600 5 5 procd_set_param stdout 1 procd_set_param stderr 1 procd_close_instance }
В веб-панели AdGuard Home (192.168.1.1:3000) настраиваем:
Upstream DNS:
https://1.1.1.1/dns-queryиhttps://8.8.8.8/dns-queryЧёрные списки: OISD (https://small.oisd.nl) — блокирует рекламу и трекеры
Итоговая цепочка DNS:
Устройства → AdGuard Home (порт 53, фильтрация + DoH) │ ▼ HTTPS к 1.1.1.1:443 │ ▼ nftables TPROXY → Xray → VPN-туннель
Ни один plaintext DNS-запрос не покидает роутер. Провайдер не видит ни запрашиваемые домены, ни сам факт DNS-обращений. А в качестве бонуса — реклама и трекеры фильтруются на уровне DNS для всех устройств в сети, включая Smart TV и IoT.
Xray internal DNS — отдельная история. Xray использует свой DNS для внутреннего резолва при маршрутизации (domainStrategy: IPIfNonMatch):
{ "dns": { "queryStrategy": "UseIPv4", "servers": [ {"address": "https://1.1.1.1/dns-query", "skipFallback": false}, {"address": "https://8.8.8.8/dns-query", "skipFallback": false} ] } }
queryStrategy: "UseIPv4" — запрашивать только A-записи. IPv6 отключён на уровне сети (см. раздел «Известные проблемы»).
TPROXY: перехват трафика
Это ключевая часть всей системы. Разберём её детально.
Что происходит с пакетом
Вот путь пакета от устройства до Xray:
Телефон отправляет TCP SYN к
discord.com:443Пакет приходит на роутер через
br-lannftables (хук prerouting, priority mangle) перехватывает пакет
Правило TPROXY передаёт пакет Xray на 127.0.0.1:12345, ставит fwmark 0x1
Policy routing (
ip rule fwmark 1 table 100) маршрутизирует помеченный пакет в локальный стекXray получает пакет с оригинальным IP назначения
Sniffing извлекает
discord.comиз TLS SNIRouting проверяет: discord.com не в geosite:ru, не .ru — значит, proxy
Xray устанавливает VLESS+Reality соединение к VPN-серверу и передаёт данные
Модули ядра
Для работы TPROXY нужны модули ядра. Проверяем:
lsmod | grep tproxy # Должны быть: nf_tproxy_ipv4, nft_tproxy
Если модулей нет — пакеты kmod-nf-tproxy и kmod-nft-tproxy не установлены.
Policy routing
TPROXY требует, чтобы помеченные пакеты маршрутизировались в локальный стек:
ip rule add fwmark 1 table 100 ip route add local 0.0.0.0/0 dev lo table 100
Как это работает:
nftables ставит метку
0x1(fwmark) на перехваченный пакетЯдро видит метку и ищет маршрут в таблице 100 (вместо основной таблицы)
В таблице 100 единственный маршрут:
local default dev lo— все адреса считаются локальнымиЯдро доставляет пакет в локальный стек, где его подхватывает Xray на порту 12345
Без этого помеченный пакет ушёл бы по обычному маршруту (через WAN) и не попал бы в Xray.
Важный нюанс: команда ip rule add не проверяет дубликаты. Если вызвать её несколько раз — правила накопятся. Поэтому в init-скрипте перед добавлением нужно очистить все старые правила:
while ip rule del fwmark 1 table 100 2>/dev/null; do :; done ip rule add fwmark 1 table 100
Правила nftables
В первой версии я добавлял правила по одному через nft add rule. Это работало, но было хрупко: легко забыть правило, порядок мог перепутаться. В финальной версии я генерирую nft-файл и загружаю его атомарно через nft -f:
table ip xray { chain prerouting { type filter hook prerouting priority mangle; policy accept; # 1. Bypass приватных подсетей ip daddr { 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } return # 2. Bypass трафика самого Xray (защита от петли) meta mark 0xff return # 3. Bypass IP VPN-серверов (автоматически из конфига) ip daddr { 1.2.3.4, 5.6.7.8, 9.10.11.12 } return # 4. DHCP — не трогать udp dport { 67, 68 } return # 5. QUIC drop — блокируем UDP 443 с LAN iifname "br-lan" udp dport 443 drop # 6. TCP TPROXY — перехват и маркировка iifname "br-lan" meta l4proto tcp tproxy to 127.0.0.1:12345 meta mark set 1 accept } }
Разберём каждое правило:
Bypass приватных IP (правило 1) — трафик к локальным адресам не должен попадать в прокси. Без этого перестанут работать LuCI (192.168.1.1), SSH, принтеры, NAS, IoT-устройства — всё, что живёт в локальной сети.
Bypass Xray-трафика через meta mark (правило 2) — ключевая защита от петли. Xray ставит SO_MARK (meta mark 0xff) на свои исходящие соединения к VPN-серверам. Это правило пропускает такие пакеты мимо TPROXY. Без него Xray попытается подключиться к серверу → пакет перехватится nftables → отправится обратно в Xray → бесконечный цикл, который съест CPU и положит сеть.
Bypass серверных IP (правило 3) — дополнительная подстраховка. IP-адреса VPN-серверов извлекаются автоматически из config.json при каждом запуске init-скрипта:
extract_server_ips() { grep -o '"address"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONF" 2>/dev/null | \ sed 's/.*"\([^"]*\)"$/\1/' | \ grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | \ sort -u }
Функция парсит JSON grep’ом (jq на OpenWrt нет), вытаскивает все значения "address" и фильтрует только IPv4. Результат подставляется в nft-файл. Никакого ручного хардкода — обновилась подписка, рестартнул сервис, bypass-список обновился автоматически.
Зачем два уровня защиты от петли (meta mark + bypass IP)? meta mark 0xff — основная защита, но она работает только для соединений, которые Xray уже установил. При начальном DNS-резолве адреса сервера пакет может не иметь метки. Bypass по IP — страховка на этот случай.
Блокировка QUIC (правило 5) — отдельная тема. QUIC (HTTP/3) — это UDP на порту 443. В текущей конфигурации Xray проксирует только TCP. Если не заблокировать QUIC, произойдёт следующее:
Браузер пытается подключиться к YouTube по QUIC (UDP 443)
UDP не перехватывается TPROXY (нет правила для UDP)
Пакет уходит напрямую через WAN, минуя VPN
YouTube работает без VPN → блокировка не обходится
Дроп QUIC заставляет браузеры fallback’иться на TCP (HTTPS), который уже перехватывается TPROXY. Да, HTTP/3 теряется, но обход блокировок важнее.
Альтернатива — проксировать UDP тоже. Для этого нужно добавить UDP-правило в nftables и UDP inbound в Xray. Это сложнее и менее стабильно (UDP через прокси работает хуже TCP), но возможно.
TCP TPROXY (правило 6) — собственно перехват. Только с интерфейса br-lan (LAN), только TCP. Пакет передаётся Xray на 127.0.0.1:12345, получает метку 0x1 для policy routing.
iifname "br-lan" — перехватываем только трафик с LAN. Трафик самого роутера (output) и трафик с WAN не трогаем.
Geodata: откуда берутся списки
Xray использует два файла для маршрутизации:
geoip.dat — база GeoIP (привязка IP-адресов к странам). Стандартная от V2Fly. Содержит категорию geoip:ru — все IP-блоки, зарегистрированные в России.
geosite.dat — база доменов. Я использую кастомную от проекта runetfreedom/russia-v2ray-rules-dat. Она содержит категорию ru-available-only-inside — домены, которые работают только из РФ: госуслуги, банки, некоторые госсервисы. Если пустить их через зарубежный VPN — они вернут ошибку или заблокируют доступ.
Файлы скачиваются при запуске и хранятся в /tmp/xray-assets/ (tmpfs):
ASSET_DIR="/tmp/xray-assets" GEOIP_URL="https://cdn.jsdelivr.net/gh/v2fly/geoip@release/geoip.dat" GEOSITE_URL="https://raw.githubusercontent.com/runetfreedom/russia-v2ray-rules-dat/release/geosite.dat" mkdir -p "$ASSET_DIR" curl -fsSL -o "$ASSET_DIR/geoip.dat" "$GEOIP_URL" curl -fsSL -o "$ASSET_DIR/geosite.dat" "$GEOSITE_URL"
О потреблении памяти: geoip.dat ~20 МБ, geosite.dat ~65 МБ — суммарно ~85 МБ в tmpfs. Это значительная часть tmpfs (у меня 242 МБ). На роутерах с 256 МБ RAM это может стать проблемой. Если памяти мало — можно хранить geodata на overlay (Flash), но это увеличит время запуска из-за медленного NAND.
Init-скрипт: собираем всё вместе
Init-скрипт использует procd — систему управления процессами OpenWrt. procd мониторит Xray и автоматически перезапускает его при падении. Также скрипт следит за изменениями конфига — если config.json обновился (например, скриптом подписки), procd перезапустит Xray.
#!/bin/sh /etc/rc.common USE_PROCD=1 START=99 STOP=10 CONF="/etc/xray/config.json" ASSET_DIR="/tmp/xray-assets" GEOIP_URL="https://cdn.jsdelivr.net/gh/v2fly/geoip@release/geoip.dat" GEOSITE_URL="https://raw.githubusercontent.com/runetfreedom/russia-v2ray-rules-dat/release/geosite.dat" download_assets() { mkdir -p "$ASSET_DIR" [ -s "$ASSET_DIR/geosite.dat" ] && [ -s "$ASSET_DIR/geoip.dat" ] && return 0 local i=0 while [ "$i" -lt 3 ]; do i=$((i + 1)) curl -fsSL --connect-timeout 10 --max-time 120 \ -o "$ASSET_DIR/geoip.dat" "$GEOIP_URL" && \ curl -fsSL --connect-timeout 10 --max-time 120 \ -o "$ASSET_DIR/geosite.dat" "$GEOSITE_URL" && \ [ -s "$ASSET_DIR/geosite.dat" ] && [ -s "$ASSET_DIR/geoip.dat" ] && return 0 sleep 2 done return 1 } # Автоизвлечение IP VPN-серверов из конфига для bypass в nftables extract_server_ips() { grep -o '"address"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONF" 2>/dev/null | \ sed 's/.*"\([^"]*\)"$/\1/' | \ grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | \ sort -u } setup_network() { # Policy routing (очистка дублей + добавление) while ip rule del fwmark 1 table 100 2>/dev/null; do :; done ip route flush table 100 2>/dev/null ip rule add fwmark 1 table 100 ip route add local 0.0.0.0/0 dev lo table 100 # Собираем bypass-список IP серверов из конфига local bypass_ips bypass_ips=$(extract_server_ips | tr '\n' ',' | sed 's/,$//') # Удаляем старую таблицу nft delete table ip xray 2>/dev/null # Генерируем nft-файл local nft_file="/tmp/xray.nft" cat > "$nft_file" << NFT table ip xray { chain prerouting { type filter hook prerouting priority mangle; policy accept; ip daddr { 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } return meta mark 0xff return NFT # Bypass IP серверов (только если есть IP-адреса в конфиге) [ -n "$bypass_ips" ] && \ echo " ip daddr { $bypass_ips } return" >> "$nft_file" cat >> "$nft_file" << 'NFT' udp dport { 67, 68 } return iifname "br-lan" udp dport 443 drop iifname "br-lan" meta l4proto tcp tproxy to 127.0.0.1:12345 meta mark set 1 accept } } NFT # Атомарная загрузка правил nft -f "$nft_file" || { logger -t xray-tproxy "nftables apply failed" rm -f "$nft_file" return 1 } rm -f "$nft_file" logger -t xray-tproxy "Network ready (bypass: ${bypass_ips:-none})" } start_service() { download_assets || { logger -t xray-tproxy "Geo assets download failed" return 1 } setup_network || return 1 procd_open_instance "xray" procd_set_param command /usr/bin/xray run -c "$CONF" procd_set_param env XRAY_LOCATION_ASSET="$ASSET_DIR" procd_set_param stdout 1 procd_set_param stderr 1 procd_set_param respawn 3600 5 5 procd_set_param limits core="unlimited" procd_set_param limits nofile="1000000 1000000" procd_set_param file "$CONF" procd_close_instance } stop_service() { nft delete table ip xray 2>/dev/null while ip rule del fwmark 1 table 100 2>/dev/null; do :; done ip route flush table 100 2>/dev/null logger -t xray-tproxy "Stopped, network cleaned" } service_triggers() { procd_add_reload_trigger "xray" }
Разберём ключевые решения:
USE_PROCD=1 — вместо простого start()/stop() используем procd API. Это даёт автоматический перезапуск при падении, мониторинг процесса и корректную обработку сигналов.
procd_set_param respawn 3600 5 5 — если Xray упадёт, procd перезапустит его. Параметры: порог (3600 секунд), задержка (5 секунд), максимум рестартов (5). Если за час Xray упал 5 раз — procd перестанет перезапускать (значит, проблема серьёзнее).
procd_set_param file "$CONF" — procd следит за файлом конфига. Когда скрипт обновления подписки перезаписывает config.json, procd перезапустит Xray.
extract_server_ips() — парсит config.json, вытаскивает все IPv4-адреса из полей "address" и подставляет в bypass-список nftables. Никакого ручного хардкода IP — обновилась подписка, рестартнул сервис, bypass обновился.
Атомарная загрузка nftables — правила записываются в файл и загружаются одной командой nft -f. Если файл содержит ошибку — ни одно правило не применится (атомарность). При поштучном nft add rule ошибка в середине оставит таблицу в полуприменённом состоянии.
setup_network() очищает дубли — while ip rule del ... ; do :; done удаляет все накопившиеся правила перед добавлением нового. В ранней версии скрипта я этого не делал, и за 3 дня аптайма накопилось 14 дублирующих ip rule — теперь этого не происходит.
Активируем:
chmod +x /etc/init.d/xray-tproxy /etc/init.d/xray-tproxy enable /etc/init.d/xray-tproxy start
Важно: если у вас стоит стоковый init-скрипт xray (из пакета xray-core) — отключите его, чтобы не было конфликта:
/etc/init.d/xray disable
Автоматическое обновление подписки
Большинство VPN-провайдеров, поддерживающих VLESS, предоставляют подписку — URL, который возвращает base64-закодированный список серверов в формате vless://.... Серверы могут меняться: добавляются новые, удаляются заблокированные. Чтобы не обновлять конфиг вручную, я написал скрипт автообновления.
У меня подписок две — от разных провайдеров. Это и отказоустойчивость (один лёг — второй работает), и больше серверов для балансировки. Скрипт обрабатывает обе, и если одна недоступна — это не фатальная ошибка.
Проблема: на OpenWrt нет base64
BusyBox на OpenWrt — минимальная среда. Нет base64, нет jq, нет python. А подписка приходит в base64. Что делать?
Писать base64-декодер на awk. Да, это возможно, и это самый хакерский кусок всей системы:
awk ' BEGIN { a="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" for(i=1;i<=length(a);i++) m[substr(a,i,1)]=i-1 } { for(i=1;i<=length($0);i++){ c=substr($0,i,1) if(c~/[A-Za-z0-9+\/=]/) s=s c } } END { for(i=1;i<=length(s);i+=4){ c1=substr(s,i,1); c2=substr(s,i+1,1) c3=substr(s,i+2,1); c4=substr(s,i+3,1) v1=m[c1]; v2=m[c2] printf "%c", int(v1*4+int(v2/16)) if(c3=="=") break v3=m[c3] printf "%c", int((v2%16)*16+int(v3/4)) if(c4=="=") continue v4=m[c4] printf "%c", int((v3%4)*64+v4) } }' input.b64 > output.txt
20 строк awk — полноценный base64-декодер, работающий на любом BusyBox.
Архитектура скрипта
Скрипт вырос из простого «скачай-распарси-подставь» в полноценный инструмент на ~200 строк ash. Общий алгоритм:
Fetch — скачиваем подписки по URL (две штуки, независимо)
Decode — base64 → текст (по одной VLESS-ссылке на строку)
Filter — отбрасываем специализированные ноды (YouTube-ноды, антизаглушки и т.д.)
Parse — из каждой ссылки извлекаем параметры и генерируем Xray JSON outbound
Build — собираем конфиг из шаблона, подставляя список серверов
Validate —
xray run -test -c config.jsonпроверяет синтаксисApply — бэкап старого конфига → замена → перезапуск
Ключевое отличие от первой версии — ничего не захардкожено. Ни один сервер не вшит в скрипт. Оба провайдера парсятся динамически. Теги серверов — proxy-provider1-1..N и proxy-provider2-1..N — все матчатся селектором proxy-* в балансировщике.
Универсальный парсер vless_to_json()
Самая полезная часть скрипта — функция, которая принимает vless:// URL и генерирует готовый Xray JSON outbound:
vless_to_json() { local url="$1" tag="$2" # Извлекаем базовые параметры из URL local uuid host port uuid=$(echo "$url" | sed -n 's|vless://\([^@]*\)@.*|\1|p') host=$(echo "$url" | sed -n 's|.*@\([^:]*\):.*|\1|p') port=$(echo "$url" | sed -n 's|.*:\([0-9]*\).*|\1|p') # Извлекаем параметры из query string local pbk sni sid tp flow fp pbk=$(echo "$url" | sed -n 's|.*pbk=\([^&]*\).*|\1|p') sni=$(echo "$url" | sed -n 's|.*sni=\([^&]*\).*|\1|p') sid=$(echo "$url" | sed -n 's|.*sid=\([^&]*\).*|\1|p') tp=$(echo "$url" | sed -n 's|.*type=\([^&]*\).*|\1|p') flow=$(echo "$url" | sed -n 's|.*flow=\([^&]*\).*|\1|p') fp=$(echo "$url" | sed -n 's|.*fp=\([^&]*\).*|\1|p') # Значения по умолчанию [ -z "$sni" ] && sni="$host" [ -z "$tp" ] && tp="tcp" [ -z "$fp" ] && fp="chrome" # flow — только для TCP (XTLS-Vision не работает с XHTTP) local flow_line="" if [ "$tp" = "tcp" ] && [ -n "$flow" ]; then flow_line="\"flow\": \"$flow\"," fi # shortId — опционален local sid_line="" [ -n "$sid" ] && sid_line="\"shortId\": \"$sid\"," # Транспорт: TCP или XHTTP local network_block if [ "$tp" = "xhttp" ]; then local path path=$(echo "$url" | sed -n 's|.*path=\([^&]*\).*|\1|p' | \ sed 's|%2F|/|g') network_block="\"network\": \"xhttp\", \"xhttpSettings\": {\"path\": \"${path:-/}\"}" else network_block="\"network\": \"tcp\"" fi cat <<EOJ { "tag": "$tag", "protocol": "vless", "settings": { "vnext": [{ "address": "$host", "port": $port, "users": [{ "id": "$uuid", $flow_line "encryption": "none" }] }] }, "streamSettings": { $network_block, "security": "reality", "realitySettings": { "serverName": "$sni", "publicKey": "$pbk", $sid_line "fingerprint": "$fp" } } } EOJ }
Функция автоматически определяет:
Транспорт — TCP или XHTTP (из параметра
type). Некоторые провайдеры дают запасные конфиги на XHTTP с маскировкой SNI под известные домены — для случаев, когда TCP заблокирован.flow — XTLS-Vision включается только для TCP (для XHTTP он не поддерживается и вызовет ошибку).
shortId — опционален, подставляется только если есть.
SNI — берётся из параметра
sni, а если его нет — используетсяhost.
Один парсер для любых VLESS-ссылок, независимо от провайдера.
Фильтрация нод: unicode-ловушка
Подписки содержат специализированные ноды: выделенные для YouTube, для AI-сервисов, «антизаглушки» для мобильных сетей. Для роутера они не нужны — нам нужны только общие серверы.
Наивный подход — grep -i "youtube" — не работает. Почему? Потому что некоторые провайдеры используют юникодные small caps в именах нод: ʏᴏᴜᴛᴜʙᴇ вместо youtube, ᴀɪ вместо ai, ʀᴜ вместо ru. Обычный grep -i не ловит эти символы — они находятся в другом диапазоне Unicode и не являются uppercase/lowercase-парами латинских букв.
Решение — отдельная функция с явными проверками:
should_skip() { local name="$1" # Обычные ASCII-паттерны echo "$name" | grep -Eiq "youtube|обход|россия|russia|антизаглушка" && return 0 # Unicode small caps (VPN-провайдеры маскируют имена нод) echo "$name" | grep -q "ʏᴏᴜᴛᴜʙᴇ" && return 0 echo "$name" | grep -q "ᴀɪ" && return 0 echo "$name" | grep -q "ᴩоᴄᴄия" && return 0 echo "$name" | grep -q "ʀᴜ" && return 0 return 1 }
Первая строка ловит стандартные паттерны. Остальные — побитовые проверки на конкретные юникодные строки. Неизящно, но надёжно.
Ещё один нюанс: провайдер может включать в имя ноды разделитель | (например, 🛜 🇫🇮 Финляндия | HK). Ранняя версия скрипта отбрасывала все такие ноды — а это были все серверы одного из провайдеров. Баг, который тихо убивал половину серверов из пула. Сейчас | в имени не является критерием для пропуска.
Зачем вообще нужен фильтр специализированных нод? Потому что подписки содержат серверы, предназначенные для конкретных задач. Например, «антизаглушка» — нода на IP российского облака с SNI известного российского домена. Она предназначена для обхода белых списков ТСПУ на мобильных сетях: ТСПУ не блокирует соединения к «российским» IP и доменам. Для роутера такие ноды бесполезны и могут даже навредить маршрутизации.
Двойной fetch и отказоустойчивость
Скрипт обрабатывает обе подписки независимо:
# Провайдер 1 if fetch_and_decode "$URL_PROVIDER1" "$TMP/provider1.dec"; then parse_nodes "$TMP/provider1.dec" "proxy-p1" "$TMP/outbounds.json" fi # Провайдер 2 if fetch_and_decode "$URL_PROVIDER2" "$TMP/provider2.dec"; then parse_nodes "$TMP/provider2.dec" "proxy-p2" "$TMP/outbounds.json" fi # Должен быть хотя бы один живой сервер [ "$TOTAL_COUNT" -gt 0 ] || { log "FAIL: no nodes from any provider"; exit 1; }
Если один провайдер недоступен — его серверы не попадают в конфиг, но серверы второго провайдера всё равно подтягиваются. Фатальная ошибка — только если оба источника пусты.
Теги серверов — proxy-p1-1, proxy-p1-2, proxy-p2-1, proxy-p2-2 и т.д. Все начинаются с proxy-, что матчится селектором балансировщика proxy-*. Xray не знает и не должен знать, от какого провайдера сервер — он просто выбирает самый быстрый.
Шаблон конфига
Скрипт использует шаблон /etc/xray/config.template.json, в котором все секции статичны, кроме outbounds:
{ "log": {"loglevel": "warning"}, "dns": { "queryStrategy": "UseIPv4", "servers": [ {"address": "https://1.1.1.1/dns-query", "skipFallback": false}, {"address": "https://8.8.8.8/dns-query", "skipFallback": false} ] }, "observatory": { "subjectSelector": ["proxy-"], "probeURL": "http://cp.cloudflare.com", "probeInterval": "120s", "enableConcurrency": true }, "inbounds": [ { "tag": "tproxy-in", "port": 12345, "protocol": "dokodemo-door", "settings": {"network": "tcp", "followRedirect": true}, "sniffing": { "enabled": true, "destOverride": ["http", "tls"], "routeOnly": true }, "streamSettings": {"sockopt": {"tproxy": "tproxy"}} } ], "outbounds": [ __OUTBOUNDS__, {"tag": "direct", "protocol": "freedom"}, {"tag": "block", "protocol": "blackhole"} ], "routing": { "domainStrategy": "IPIfNonMatch", "balancers": [ {"tag": "vpn-auto", "selector": ["proxy-"], "strategy": {"type": "leastPing"}} ], "rules": [ {"type": "field", "ip": ["geoip:private"], "outboundTag": "direct"}, {"type": "field", "ip": ["geoip:ru"], "outboundTag": "direct"}, {"type": "field", "domain": ["geosite:ru-available-only-inside"], "outboundTag": "direct"}, {"type": "field", "domain": ["regexp:.*\\.ru$", "regexp:.*\\.su$", "regexp:.*\\.xn--p1ai$"], "outboundTag": "direct"}, {"type": "field", "network": "tcp", "balancerTag": "vpn-auto"} ] } }
Плейсхолдер __OUTBOUNDS__ заменяется скриптом на JSON-массив серверов. Это позволяет обновлять список серверов, не трогая логику маршрутизации.
Cron
crontab -e
# Обновление подписки каждые 30 минут (лог в файл) */30 * * * * /usr/bin/xray-update-safe "https://your-subscription-url" >> /tmp/xray-update.log 2>&1 # Ротация лога обновлений каждые 6 часов 0 */6 * * * : > /tmp/xray-update.log # Watchdog: если Xray мёртв — чистим nftables и перезапускаем * * * * * pidof xray >/dev/null || { nft delete table ip xray 2>/dev/null; /etc/init.d/xray-tproxy start; logger -t xray-watchdog "Xray was dead, restarted"; }
Лог обновлений пишется в отдельный файл /tmp/xray-update.log — удобно для отладки (cat /tmp/xray-update.log), и не смешивается с основным логом Xray.
Watchdog-строка — страховка от ситуации, когда procd сдался. procd_set_param respawn 3600 5 5 перезапускает Xray максимум 5 раз за час, после чего сдаётся. Если Xray упал 6-й раз — procd молчит, а nftables-правила остаются: пакеты маркируются, уходят в TPROXY, но Xray их не принимает → чёрная дыра, интернет мёртв. Watchdog в cron проверяет каждую минуту: Xray жив? Если нет — сначала удаляет таблицу nftables (восстанавливая прямой доступ), потом перезапускает сервис.
Hotplug: восстановление после network restart
Есть неочевидная проблема: /etc/init.d/network restart сбрасывает policy routing (ip rule, ip route). При этом nftables-правила TPROXY остаются. Результат: пакеты маркируются fwmark 0x1, но маршрут для этой метки не существует — пакеты дропаются. Интернет мёртв, и без ручного xray-tproxy restart не починится.
Решение — hotplug-скрипт, который автоматически восстанавливает Xray при поднятии WAN:
cat > /etc/hotplug.d/iface/99-xray-fix << 'EOF' #!/bin/sh [ "$ACTION" = "ifup" ] && [ "$INTERFACE" = "wan" ] && { ip rule show | grep -q "fwmark 0x1" || /etc/init.d/xray-tproxy restart } EOF chmod +x /etc/hotplug.d/iface/99-xray-fix
Логика: когда WAN поднимается (ifup), скрипт проверяет, есть ли ip rule для fwmark. Если нет — policy routing потерялся, нужен рестарт xray-tproxy. Если правило на месте — ничего не делаем (обычный переподключение PPPoE не трогает ip rule).
Мониторинг и отладка
Проверка что всё работает
# Xray запущен? ps | grep xray # TPROXY правила на месте? nft list table ip xray # Policy routing настроен? ip rule show | grep fwmark ip route show table 100 # Тест: соединение идёт через VPN? curl -x socks5://127.0.0.1:12345 https://ifconfig.me # Или с клиентского устройства: # curl https://ifconfig.me — должен показать IP VPN-сервера, а не ваш
Логи Xray
Xray пишет лог в stdout/stderr, который procd перенаправляет в системный лог. Смотрим через logread:
logread -e xray
Лог обновлений подписки — отдельно:
cat /tmp/xray-update.log
Типичные сообщения:
# Сервер жив app/observatory: probe proxy-1 succeeded # Сервер мёртв app/observatory: the outbound proxy-3 is dead # Sniffing извлёк домен transport/internet/tcp: dialing TCP to tcp:discord.com:443 # Маршрут → direct app/router: taking detour [direct] for [tcp:yandex.ru:443] # Маршрут → proxy app/router: taking detour [vpn-auto] for [tcp:youtube.com:443]
Типичные проблемы
«Нет интернета после включения TPROXY» — скорее всего Xray не запущен или упал. Проверьте ps | grep xray. Без Xray пакеты уходят в TPROXY и пропадают — никто их не обрабатывает.
«Локальная сеть недоступна» — забыли bypass для приватных подсетей в nftables. Трафик к 192.168.1.1 уходит в Xray вместо локального стека.
«VPN-серверы не подключаются, петля» — IP VPN-сервера не добавлен в bypass. Трафик к серверу перехватывается → Xray пытается подключиться → перехватывается → бесконечный цикл.
«Госуслуги / Сбер не работают» — проверьте, что geosite.dat — кастомная (с ru-available-only-inside), а не стандартная. Стандартная не содержит эту категорию.
«YouTube тормозит» — если QUIC не заблокирован, браузер может использовать UDP-транспорт, который не проксируется. Проверьте nft list table ip xray | grep "udp dport 443".
Потребление ресурсов
На моём Cudy TR3000 (2× Cortex-A53, 496 МБ RAM):
CPU: ~0.08 load average в простое. Xray нагружает CPU только при активном трафике, и даже при стриминге 4K YouTube нагрузка не превышает 30–40% одного ядра (благодаря XTLS-Vision — нет двойного шифрования).
RAM: ~108 МБ используется. Xray Go runtime резервирует ~1.3 ГБ виртуальной памяти, но физически потребляет ~100 МБ.
tmpfs: ~165 МБ из 242 МБ (geoip.dat ~20 МБ, geosite.dat ~65 МБ — основные потребители).
Flash overlay: 26 МБ из 44 МБ.
Uptime: стабильная работа неделями без перезагрузок.
Известные проблемы и пути решения
Не буду притворяться, что система идеальна. Вот оставшиеся несовершенства:
1. Observatory не работает с Reality (IP-based SNI)
Серверы, где SNI = IP-адрес, определяются как dead. Балансировка leastPing фактически не работает для них.
Решение: использовать серверы с доменным SNI. Или смириться и использовать один надёжный сервер с доменным SNI как «якорь» балансировки.
2. Geodata в tmpfs
~85 МБ geodata загружаются в tmpfs при каждом запуске. Если роутер перезагрузится без интернета (WAN не поднялся) — geodata не скачаются, Xray не запустится.
Решение: хранить копию geodata на overlay (Flash), обновлять периодически через cron, использовать как fallback если скачать не удалось:
# В download_assets() if ! curl ... -o "$ASSET_DIR/geoip.dat" ...; then cp /etc/xray/geoip.dat.bak "$ASSET_DIR/geoip.dat" 2>/dev/null fi
Что уже починено
Система прошла через несколько итераций. Вот проблемы, которые были найдены и решены:
DNS-утечка (UDP) — самый коварный баг. dnsmasq форвардил DNS на 1.1.1.1 по UDP, а TPROXY перехватывает только TCP. Plaintext UDP-запросы уходили напрямую — провайдер видел все запрашиваемые домены. Промежуточно решено через
https-dns-proxy, финально — заменой на AdGuard Home с DoH upstream. Теперь DNS идёт только через HTTPS, который проксируется TPROXY.IPv6-утечка — ULA-префикс был активен, wan6 поднят, dnsmasq возвращал AAAA-записи. Современные устройства предпочитают IPv6, и весь этот трафик шёл мимо VPN. Решено полным отключением IPv6:
uci set network.globals.ula_prefix='' uci set network.wan6.auto='0' uci set dhcp.@dnsmasq[0].filter_aaaa='1' uci commit network && uci commit dhcp /etc/init.d/network restart && /etc/init.d/dnsmasq restart
Потеря ip rule после network restart —
network restartсбрасывает policy routing, но nftables остаётся → чёрная дыра. Решено hotplug-скриптом/etc/hotplug.d/iface/99-xray-fix, который восстанавливает Xray при поднятии WAN.procd сдаётся после 5 падений —
respawn 3600 5 5перестаёт перезапускать Xray, nftables остаётся, интернет мёртв. Решено watchdog в cron, который каждую минуту проверяетpidof xrayи при необходимости чистит nftables + перезапускает.Нет автоперезапуска — Xray запускался через
&(фоновый процесс) без мониторинга. Упал — лежит. Решено переходом на procd сrespawn.Дубли ip rule — при каждом рестарте добавлялось новое правило, старые не удалялись. За 3 дня — 14 дублей. Решено циклом
while ip rule del ... ; do :; doneперед добавлением.Хардкод IP серверов в bypass — при обновлении подписки нужно было вручную обновлять IP в nftables. Решено автоизвлечением IP из config.json через
extract_server_ips().Расхождение nftables-конфигов — файл
tproxy.nftсодержал правильные правила, но init-скрипт добавлял правила вручную с другой логикой. Решено переходом на единый nft-файл, генерируемый скриптом.
Заметка о безопасности: User-Agent скрипта обновления
В апреле 2026 года вышло исследование, показавшее критическую уязвимость в популярных VLESS-клиентах для Android/iOS: открытый SOCKS5 без авторизации на localhost позволяет шпионским модулям в других приложениях обнаружить выходной IP прокси, а в некоторых клиентах — дампить полные конфиги через Xray API.
Для роутерного решения эта уязвимость не актуальна: на роутере нет шпионских приложений, нет SOCKS5 на localhost для сканирования, нет других приложений вообще. Но есть нюанс: некоторые провайдеры подписок начали блокировать запросы с User-Agent уязвимых клиентов. Если ваш скрипт обновления использует User-Agent конкретного мобильного клиента — смените его на что-нибудь нейтральное (XrayRouter/1.0, curl/8.0 и т.д.), чтобы не попасть под фильтр.
Итог: что получилось
Все устройства в домашней сети — от ноутбука до телевизора — автоматически получают проксирование заблокированных ресурсов. Российские сайты работают напрямую, без VPN-задержки. DNS-запросы шифруются через DoH и фильтруются от рекламы через AdGuard Home. IPv6-утечка закрыта. Серверы обновляются из двух подписок каждые 30 минут. Watchdog и hotplug-хук гарантируют, что система восстановится после любого сбоя.
На всё ушло несколько вечеров: прошивка, конфиг Xray и nftables, скрипт автообновления, затем итерации по закрытию утечек DNS и IPv6, добавление AdGuard Home, hotplug и watchdog. Каждая итерация — реакция на реальную проблему, обнаруженную в продакшене.
Самое сложное во всей настройке — не конфиги (их можно скопировать), а понимание, почему каждый элемент нужен. Без этого любая нештатная ситуация (новый сервер, смена провайдера, падение Xray) превратится в чёрную коробку. Надеюсь, эта статья объяснила не только «как», но и «почему».
Если что-то не заработало или есть вопросы по конкретным нюансам — спрашивайте в комментариях. Особенно если у вас другое железо или провайдер с DPI построже.
