У меня дома около десяти устройств: ноутбук, телефон, телевизоры, колонки. На каждом — свои приложения, которым нужен доступ к заблокированным ресурсам. Ставить 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 на пятнадцати устройствах.


Выбор железа

Не каждый роутер подойдёт. Нужны три вещи:

  1. Поддержка OpenWrt — без неё ничего не получится. Проверяйте на openwrt.org/toh.

  2. Достаточно RAM — Xray-core написан на Go и потребляет ~100 МБ физической памяти. Плюс geodata (~85 МБ в tmpfs). Роутеры с 128 МБ RAM не потянут. Минимум — 256 МБ, комфортно — 512 МБ.

  3. Нормальный 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, 1× 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/nftables

  • kmod-nft-fib — FIB lookup, нужен для policy routing в nftables

  • nftables-json — утилита nft с поддержкой JSON

  • curl — для скачивания 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"}
  ]
}

Правила применяются сверху вниз, первое совпадение побеждает:

  1. Частные 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-устройства.

  2. Российские IP (geoip:ru) → direct. Зачем проксировать трафик к Яндексу или ВК? Он и так работает. Проксирование только замедлит.

  3. Сайты, работающие только из РФ (geosite:ru-available-only-inside) → direct. Это кастомная категория из проекта runetfreedom/russia-v2ray-rules-dat: госуслуги, банки, сервисы, которые блокируют зарубежный трафик. Если пустить их через VPN-сервер за рубежом — они перестанут работать.

  4. Домены .ru / .su / .рфdirect. Регулярные выражения ловят все домены в этих зонах. .xn--p1ai — это punycode-кодировка .рф.

  5. Всё остальное TCPvpn-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:

  1. Телефон отправляет TCP SYN к discord.com:443

  2. Пакет приходит на роутер через br-lan

  3. nftables (хук prerouting, priority mangle) перехватывает пакет

  4. Правило TPROXY передаёт пакет Xray на 127.0.0.1:12345, ставит fwmark 0x1

  5. Policy routing (ip rule fwmark 1 table 100) маршрутизирует помеченный пакет в локальный стек

  6. Xray получает пакет с оригинальным IP назначения

  7. Sniffing извлекает discord.com из TLS SNI

  8. Routing проверяет: discord.com не в geosite:ru, не .ru — значит, proxy

  9. 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

Как это работает:

  1. nftables ставит метку 0x1 (fwmark) на перехваченный пакет

  2. Ядро видит метку и ищет маршрут в таблице 100 (вместо основной таблицы)

  3. В таблице 100 единственный маршрут: local default dev loвсе адреса считаются локальными

  4. Ядро доставляет пакет в локальный стек, где его подхватывает 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, произойдёт следующее:

  1. Браузер пытается подключиться к YouTube по QUIC (UDP 443)

  2. UDP не перехватывается TPROXY (нет правила для UDP)

  3. Пакет уходит напрямую через WAN, минуя VPN

  4. 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. Общий алгоритм:

  1. Fetch — скачиваем подписки по URL (две штуки, независимо)

  2. Decode — base64 → текст (по одной VLESS-ссылке на строку)

  3. Filter — отбрасываем специализированные ноды (YouTube-ноды, антизаглушки и т.д.)

  4. Parse — из каждой ссылки извлекаем параметры и генерируем Xray JSON outbound

  5. Build — собираем конфиг из шаблона, подставляя список серверов

  6. Validatexray run -test -c config.json проверяет синтаксис

  7. 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 restartnetwork 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 построже.