Дисклеймер. Материал — научно-техническое описание администрирования собственной сетевой инфраструктуры на базе OpenWrt. Продолжение первой части, где я рассказывал о настройке защищённого канала связи через VLESS+Reality и прозрачное проксирование трафика на уровне TPROXY. Все конфигурации относятся к управлению частной сетью администратора и предназначены для защиты внутренних потоков данных при работе с собственными удалёнными ресурсами.

В первой части я описал, как из коробочного OpenWrt-роутера собирается прозрачный прокси-шлюз: TPROXY на nftables, Xray с VLESS+Reality+XTLS-Vision, AdGuard Home с DoH, сплит-роутинг по geosite. Там был тщательный «как сделать с нуля» — от прошивки до первого пакета через защищённый канал.

С тех пор прошёл месяц эксплуатации в боевом режиме: Cudy TR3000 v1, аптайм 17 суток на момент написания, 0.03 load average, 192 МБ RSS из 496 МБ. И за это время у меня переписалась примерно половина системы — частично потому, что монолитный JSON-конфиг перестал быть удобным, частично из-за конкретных боевых проблем (UDP 443 ломал TPROXY, голос в мессенджерах не работал, балансировщик прибивался к одному серверу), частично из-за того, что хотелось управлять proxy-доменами без правки JSON руками. И — это важно — значительная часть переписки случилась благодаря разбору в комментариях к первой статье. Несколько архитектурных изменений (главное — переворот логики маршрутизации с proxy-by-default на direct-by-default) — это прямой ответ на дельные замечания читателей. Постарался не оставлять справедливые претензии без ответа.

В этой части — что изменилось, что добавилось, и как теперь выглядит итоговая система. С разбором того, как устроена страница http://192.168.1.1/cgi-bin/luci/admin/services/vpn-domains, на которой я добавляю новый домен в proxy-список, и как поверх Xray работает второй слой обработки UDP-пакетов через NFQUEUE.

Граф архитектуры, чтобы дальше говорить про конкретные блоки одинаковым языком:

┌─────────────────────────────────────────────────────────────────────────┐
│                  Cudy TR3000 v1 / OpenWrt 25.12.2                       │
│                                                                         │
│  Клиент в LAN ───── br-lan ─────►                                       │
│                                  │                                      │
│                                  │    ┌─────────────────────────┐       │
│                                  ├───►│ DNS-запрос UDP/53       │       │
│                                  │    │       ↓                 │       │
│                                  │    │  AdGuard Home (:53)     │       │
│                                  │    │       ↓ DoH/UDP-uplink  │       │
│                                  │    │  upstream:              │       │
│                                  │    │    1.1.1.1/dns-query    │       │
│                                  │    │    8.8.8.8/dns-query    │       │
│                                  │    │    9.9.9.10 (Quad9)     │       │
│                                  │    └─────────────────────────┘       │
│                                  │                  │                   │
│                                  │                  ▼ (HTTPS наружу,    │
│                                  │                   подхватится TPROXY)│
│                                  ▼                  │                   │
│             ┌────────────────────────────┐          │                   │
│             │ nft table ip xray          │          │                   │
│             │ hook prerouting / mangle   │ ◄────────┘                   │
│             │                            │                              │
│             │  bypass: privates,         │                              │
│             │          meta mark 0xff,   │                              │
│             │          IP прокси-серверов│                              │
│             │  drop:   UDP/443 (br-lan)  │                              │
│             │  TPROXY: TCP всех портов   │                              │
│             │          UDP 50000-65535   │                              │
│             │          UDP 599/1400      │                              │
│             └────────────────────────────┘                              │
│                              │                                          │
│                              ▼ (mark 0x1, ip rule → table 100)          │
│             ┌────────────────────────────┐                              │
│             │ /tmp/xray (port 12345)     │                              │
│             │ inbound: dokodemo-door     │                              │
│             │ sniffing: TLS / HTTP / QUIC│                              │
│             └────────────────────────────┘                              │
│                              │                                          │
│                  routing rules (15 шт):                                 │
│                              │                                          │
│         ┌────────────────────┼────────────────────┐                     │
│         ▼                    ▼                    ▼                     │
│  user-домены           geoip:ru / .ru        balancer (default):        │
│  → balancer            → direct              proxy-govpn-1..2,          │
│         │                    │                proxy-dark-1..6,          │
│         │                    │                strategy=leastPing,       │
│         │                    │                observatory probe         │
│         │                    │                    │                     │
│         └─────────┬──────────┘                    │                     │
│                   ▼                               ▼                     │
│         ┌──────────────────┐         ┌────────────────────────┐         │
│         │ freedom (direct) │         │ proxy-*: VLESS+Reality │         │
│         │ sockopt mark=255 │         │ XTLS-Vision, TCP       │         │
│         └──────────────────┘         └────────────────────────┘         │
│                   │                               │                     │
│                   ▼                               ▼                     │
│         ┌─────────────────────────────────────────────────────┐         │
│         │ nft table inet nfqws_discord                        │         │
│         │ hook postrouting / mangle+1                         │         │
│         │ oif: eth0 / pppoe-wan                               │         │
│         │   UDP 50000-65535 → queue 200                       │         │
│         │   UDP 19294-19344 → queue 200                       │         │
│         └─────────────────────────────────────────────────────┘         │
│                              │                                          │
│                              ▼                                          │
│         ┌─────────────────────────────────────────────────────┐         │
│         │ /tmp/nfqws (NFQUEUE)                                │         │
│         │ --filter-l7=discord,stun --dpi-desync=fake          │         │
│         │ --dpi-desync-repeats=6                              │         │
│         └─────────────────────────────────────────────────────┘         │
│                              │                                          │
└──────────────────────────────┼──────────────────────────────────────────┘
                               ▼
                          PPPoE / eth0 → провайдер

Дальше по порядку — что в этой схеме появилось нового по сравнению с первой частью.


Главное изменение: переворот логики маршрутизации

Это самое важное архитектурное изменение, на которое меня прямо подтолкнули комментарии к первой статье — отдельное спасибо marus_space за разбор. В первой версии у меня была схема proxy-by-default: вся не-RU-часть интернета шла через защищённый канал, в direct уходили только явно прописанные .ru-домены и geoip:ru. Логика «всё через прокси, кроме RU» — внешне самая простая.

На практике это означает, что любой неизвестный домен едет через прокси. И если на телефоне работает приложение от условного российского сервиса, у которого, например, аналитика или CDN на иностранном домене — этот трафик тоже уходит через прокси. С точки зрения сервиса вы выглядите как пользователь из условной Латвии, и приложение, мягко говоря, ведёт себя странно: где-то сразу баним, где-то не пускаем, где-то отдаём другую витрину.

И отдельно — geoip:ru и доменные .ru-списки при proxy-by-default плохо защищают от обратной утечки. Условное JS-подсказка в браузере может в любой момент дёрнуть какой-нибудь api.example.com, который не попал в direct-список, и приложение получит внешний IP моего прокси-сервера. После чего сервис прекрасно пометит профиль как «возможно использует обход», даже если я живу в Москве и захожу на их сайт каждый день. На большинстве маркетплейсов и больших сервисов это сейчас уже не теоретическая, а практическая проблема — и комментаторы первой статьи это чётко зафиксировали.

Поэтому в текущей версии логика перевёрнута:

  • default → direct (последнее правило routing’а)

  • через balancer уходят только явно перечисленные категории: AI-сервисы, мессенджеры, видеостриминг, GitHub/npm/pypi и т.п.

  • .ru-домены и geoip:ru всё равно остаются в direct отдельно — для надёжности и чтобы матчилось раньше, чем any-default

Последнее правило в routing.rules шаблона теперь выглядит так:

{ "type": "field", "network": "tcp,udp", "outboundTag": "direct" }

А не так, как было в первой версии:

{ "type": "field", "network": "tcp,udp", "balancerTag": "balancer" }

Полный порядок правил в шаблоне (15 штук, выполняются сверху вниз, первое подошедшее побеждает):

1.  inbound=tproxy-in, port=53, udp                 → direct           (DNS hijack)
2.  user-proxy-domains (через __USER_PROXY_DOMAINS__) → balancer        (мои домены)
3.  geoip:private                                   → direct           (LAN)
4.  protocol=bittorrent                             → direct           (P2P)
5.  geosite:ru-available-only-inside + .ru-домены   → direct           (явно RU)
6.  geoip:ru                                        → direct           (RU IP)
7.  steam/faceit/epic/minecraft                     → direct           (игры с low-latency)
8.  youtube/netflix/spotify + CDN                   → balancer
9.  discord/telegram/meta/whatsapp/twitter/reddit   → balancer
10. openai/anthropic/claude/gemini/grok/cursor/hf   → balancer         (AI)
11. github/npm/vercel/notion/pypi/crates            → balancer         (dev)
12. medium/coursera/speedtest/roblox/geoguessr      → balancer
13. geosite:ru-blocked                              → balancer
14. geoip:facebook/telegram/twitter/netflix         → balancer         (по IP-сетям)
15. tcp,udp (default)                               → direct

Положительный эффект — мгновенный. Любой ноунейм-домен, который JS внутри маркетплейса дёргает для антифрод-проверки, идёт с моего реального российского IP. Сервис видит обычного российского клиента, а не подозрительного человека с латвийским IP, который зачем-то заходит через VPN на онлайн-банк. Параллельно мне всё равно работают AI-сервисы, мессенджеры, видеостриминг и dev-инфраструктура — потому что они в whitelist’е.

Цена — некоторые внешние сервисы, не попавшие в whitelist, идут direct и могут не открываться (если они за geofencing’ом РФ или если провайдер их режет). Решение — добавить домен через vpn-domains add или через LuCI-страницу (про неё ниже), и через 2-3 секунды он начинает идти через balancer. Это, собственно, и есть основной use case vpn-domains: я больше не правлю шаблон при каждом «не открылся очередной AI-сервис», а просто добавляю домен в свой пользовательский список.

Альтернативный аргумент в защиту старой схемы — что proxy-by-default «безопаснее с точки зрения приватности», потому что плохой не-перечисленный домен идёт через VPN, а не из РФ. Тут вопрос приоритетов: для меня практичнее не светить VPN-IP перед российскими антифрод-системами, чем пытаться скрывать от них факт работы из РФ (что они и так знают по сотне других сигналов).


Дополнительное изменение: UDP теперь проксируется

Тоже из комментариев к первой статье — 0ka справедливо ткнул, что в моей первой схеме UDP вообще не обрабатывался, и многие сценарии (нормальный голос в WebRTC-мессенджерах, FaceTime, Telegram-звонки) попросту ломались. В первой версии у меня в tproxy-in было "network": "tcp", и в nft было только meta l4proto tcp tproxy .... UDP в принципе мимо TPROXY проходил.

В текущей версии:

{
  "tag": "tproxy-in",
  "port": 12345,
  "protocol": "dokodemo-door",
  "settings": {
    "network": "tcp,udp",
    "followRedirect": true
  },
  "sniffing": {
    "enabled": true,
    "destOverride": ["http", "tls", "quic"],
    "routeOnly": true
  },
  "streamSettings": {
    "sockopt": {
      "tproxy": "tproxy"
    }
  }
}

Inbound теперь обрабатывает tcp,udp, sniffing включает quic (помимо http и tls), и в nft-таблице добавлены явные TPROXY-правила для UDP-портов:

iifname "br-lan" udp dport 50000-65535 tproxy to 127.0.0.1:12345 meta mark set 1 accept
iifname "br-lan" udp dport { 599, 1400 } tproxy to 127.0.0.1:12345 meta mark set 1 accept

50000-65535 — диапазон voice-портов в современных WebRTC-мессенджерах, 599 и 1400 — служебные UDP для Telegram-звонков. QUIC (UDP/443) при этом по-прежнему дропается — по тем же причинам, что и в первой части (двойное шифрование на VLESS, двойной congestion control, оверхед). Поэтому браузеры спокойно падают на HTTPS поверх TCP.

Отдельный нюанс с UDP-голосом — он плохо переживает сам факт проксирования (UDP-over-TCP через VLESS добавляет задержку, голос становится «ватным»). Поэтому для voice-портов в системе работает второй слой обработки — через NFQUEUE и nfqws (про это есть отдельная секция ниже). Идея простая: UDP попадает в TPROXY, Xray смотрит правила, и для voice-трафика выбирает direct (напрямую через провайдера), а перед самим уходом в WAN nfqws делает DPI-обход на уровне postrouting hook.


Изменение 1: бинарники в overlay, исполнение из tmpfs

В первой части я установил Xray через apk add xray-core и забыл. В этой реальности — на Cudy TR3000 v1 overlay-flash всего ~44 МБ, и xray-core из репозитория OpenWrt туда не помещается вместе с geoip/geosite-базами, AdGuard Home и всем остальным.

Решение, до которого я дошёл — хранить всё сжатым на overlay, разворачивать в tmpfs при старте. Содержимое /etc/xray:

/etc/xray/config.json              14832  рабочий конфиг (regenerated)
/etc/xray/config.json.bak          14836  бэкап последней рабочей версии
/etc/xray/config.template.json     11197  шаблон с плейсхолдерами
/etc/xray/xray.gz               12264611  бинарник Xray (gzipped, ~12 МБ)
/etc/xray/nfqws.gz                124708  бинарник nfqws (~125 КБ)
/etc/xray/geoip.dat.gz           4689708  geo-данные (~4.5 МБ)
/etc/xray/user-proxy-domains.conf    245  пользовательские домены

Init-скрипт /etc/init.d/xray-tproxy распаковывает их в /tmp/xray-assets/ и /tmp/xray при первом старте:

unpack_overlay() {
    if [ ! -x "$XRAY_BIN" ] && [ -f "$OVERLAY_DIR/xray.gz" ]; then
        logger -t xray-tproxy "Unpacking xray binary from overlay"
        gunzip -c "$OVERLAY_DIR/xray.gz" > "$XRAY_BIN" && chmod +x "$XRAY_BIN"
    fi

    mkdir -p "$ASSET_DIR"
    if [ ! -s "$ASSET_DIR/geoip.dat" ] && [ -f "$OVERLAY_DIR/geoip.dat.gz" ]; then
        gunzip -c "$OVERLAY_DIR/geoip.dat.gz" > "$ASSET_DIR/geoip.dat"
    fi
    if [ ! -s "$ASSET_DIR/geosite.dat" ] && [ -f "$OVERLAY_DIR/geosite.dat.gz" ]; then
        gunzip -c "$OVERLAY_DIR/geosite.dat.gz" > "$ASSET_DIR/geosite.dat"
    fi
}

После распаковки на overlay лежит сжатые ~17 МБ, в tmpfs — распакованные ~38 МБ. Tmpfs живёт в RAM, никогда не пишется на flash, не изнашивает NAND. На каждый ребут идёт повторная распаковка — на A53 это занимает примерно 4 секунды для всего стека.

Та же схема применена для AdGuard Home и nfqws. Во всех трёх init-скриптах одинаковый паттерн: /etc/component/component.gzgunzip -c/tmp/component-binchmod +xprocd_open_instance.

Это интересная деталь для тех, кто пробовал ставить полный обвес на роутер с маленьким overlay и упирался в Permission denied: not enough space. Альтернативный путь — расширить overlay через U-Boot-перепрошивку (так делают на Cudy TR3000 поверх стандартной OpenWrt-сборки, в комментариях к первой статье читатели подсказывали этот вариант), но мне хотелось обойтись без модификации загрузчика — со сжатыми бинарниками в overlay это получается чисто.


Изменение 2: bootstrap-проблема и self-bootstrapping Xray

Один сценарий, который полностью ломал систему в первой версии, — холодный старт без актуальной geodata. Если файлов geoip.dat или geosite.dat нет на overlay (например, после первой установки), Xray просто не запустится: ему нужны эти базы, чтобы матчить geosite:ru-blocked и подобные правила. А скачать их с GitHub напрямую с роутера в РФ — отдельный квест, который сам по себе требует работающего шлюза.

Решение, до которого пришёл, — минимальный bootstrap-Xray, который запускается только для скачивания geodata, а потом убивается. Минимальный — это значит без TPROXY, без routing-rules, только один SOCKS5-инбаунд на 127.0.0.1:10808 и один outbound к надёжному VLESS-серверу:

start_minimal_proxy() {
    cat > "$MINIMAL_CONF" << 'MEOF'
{
  "log": {"loglevel": "warning"},
  "inbounds": [
    {"tag":"socks-in","port":10808,"listen":"127.0.0.1",
     "protocol":"socks","settings":{"udp":true}}
  ],
  "outbounds": [
    {"tag":"proxy","protocol":"vless","settings":{"vnext":[{
      "address":"...","port":8443,
      "users":[{"id":"...","flow":"xtls-rprx-vision","encryption":"none"}]}]},
     "streamSettings":{"network":"tcp","security":"reality",
       "realitySettings":{"serverName":"...","publicKey":"...",
         "fingerprint":"chrome","shortId":"..."}}}
  ]
}
MEOF
    "$XRAY_BIN" run -c "$MINIMAL_CONF" &
    local pid=$!
    sleep 2
    kill -0 "$pid" 2>/dev/null && { echo "$pid"; return 0; }
    return 1
}

download_assets() {
    [ -s "$ASSET_DIR/geoip.dat" ] && [ -s "$ASSET_DIR/geosite.dat" ] && return 0

    touch "$LOCKFILE"
    local proxy_pid
    proxy_pid=$(start_minimal_proxy) || { rm -f "$LOCKFILE"; return 1; }

    fetch_via_proxy "$GEOIP_URL"   "$ASSET_DIR/geoip.dat"   "geoip"
    fetch_via_proxy "$GEOSITE_URL" "$ASSET_DIR/geosite.dat" "geosite"

    kill "$proxy_pid" 2>/dev/null
    rm -f "$LOCKFILE"
}

Тонкости тут две.

Первая — lockfile. Пока работает bootstrap, watchdog на cron каждую минуту проверяет «жив ли xray». Без lockfile он бы видел минимальную копию, считал, что всё ок, потом дёргал основной init и поломал бы скачку. С lockfile watchdog просто пропускает тик:

LOCKFILE="/tmp/xray-starting.lock"

if [ -f "$LOCKFILE" ]; then
    [ -n "$(find "$LOCKFILE" -mmin +10 2>/dev/null)" ] && rm -f "$LOCKFILE"
    exit 0
fi

Дополнительная защита — если lockfile старше 10 минут, его сносят: значит, что-то пошло не так, и блокировка зависла.

Вторая тонкость — fetch через socks5h, а не socks5. В URL запросов есть домены вроде raw.githubusercontent.com, и нам нужно, чтобы DNS-резолвинг тоже шёл через прокси (буква h в socks5h):

curl --proxy socks5h://127.0.0.1:10808 -fsSL \
    --connect-timeout 15 --max-time 300 \
    -o "$dest" "$url"

Без socks5h — DNS-резолвинг идёт через системный DNS, который при холодном старте ещё не готов (AdGuard Home сам поднимается через тот же init).

После того как всё скачано, bootstrap-Xray убивается, geoip кэшируется на overlay (для следующего ребута), и стартует основной Xray с реальным конфигом.


Изменение 3: автоматический bypass IP прокси-серверов в nftables

Вот грабли, на которые я наступил после смены провайдера подписки. Получаю новые VLESS-ссылки, скрипт их парсит, конфиг собирается, Xray стартует. И ничего не работает.

Причина в том, что в nft table ip xray есть whitelist IP — адреса, до которых TPROXY не должен трогать пакет, потому что иначе получится петля: пакет от Xray к VPN-серверу попадёт обратно в TPROXY и придёт в самого Xray. В первой версии я хардкодил эти IP вручную. После смены подписки список адресов протух — TPROXY попыталась перехватить outbound-трафик самого Xray, и всё легло.

Решение — извлекать IP серверов из текущего config.json каждый раз, когда мы пересоздаём nft-таблицу:

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() {
    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

    local bypass_ips
    bypass_ips=$(extract_server_ips | tr '\n' ',' | sed 's/,$//')

    nft delete table ip xray 2>/dev/null
    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

    [ -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
        iifname "br-lan" udp dport 50000-65535 tproxy to 127.0.0.1:12345 meta mark set 1 accept
        iifname "br-lan" udp dport { 599, 1400 } tproxy to 127.0.0.1:12345 meta mark set 1 accept
    }
}
NFT

    nft -f "$nft_file" || return 1
}

grep -o '"address"...' парсит JSON примитивно (без jq, потому что в init-скрипте важно минимум зависимостей), оставляет только IPv4-адреса, дедуплицирует. Эти IP подставляются в ip daddr { ... } return как третье правило bypass — после private-сетей и meta mark 0xff (маркер от самого Xray, чтобы его исходящие пакеты не возвращались в TPROXY).

Полученная итоговая таблица в проде выглядит так:

table ip xray {
    chain prerouting {
        type filter hook prerouting priority mangle; policy accept;
        ip daddr { 10.0.0.0/8, 127.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } return
        meta mark 0x000000ff return
        ip daddr { 2.26.98.183, 82.22.36.183, 82.22.53.217, 103.7.55.61, 151.241.216.180, 178.17.49.159 } return
        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 0x00000001 accept
        iifname "br-lan" udp dport 50000-65535 tproxy to 127.0.0.1:12345 meta mark set 0x00000001 accept
        iifname "br-lan" udp dport { 599, 1400 } tproxy to 127.0.0.1:12345 meta mark set 0x00000001 accept
    }
}

Шесть IP в bypass — это адреса моих прокси-серверов, извлечённые автоматически. На следующем xray-update-safe после получения новой подписки таблица пересобирается, IP обновляются.

Здесь же видна одна важная строчка, которой не было в первой части: iifname "br-lan" udp dport 443 drop. Это форсирование HTTP/2. Современные браузеры пытаются установить QUIC-соединение по UDP/443 первым, и только при неудаче падают на HTTP/2 поверх TCP/443. QUIC не проходит через TPROXY (точнее, проходит — но Xray с ним работает плохо в моей схеме, потому что sniffing UDP-QUIC намного сложнее, чем TLS на TCP). Поэтому проще всего — просто дропать UDP/443 на входе: браузер не получает ответа, через секунду переключается на HTTPS поверх TCP, и всё работает штатно. Вне сети — никакой разницы для пользователя.


Изменение 4: шаблон вместо монолита

Вместо одного config.json теперь два файла:

/etc/xray/config.template.json     шаблон с плейсхолдерами
/etc/xray/config.json              рабочий конфиг (регенерируется)

В шаблоне две точки подстановки: __OUTBOUNDS__ и __USER_PROXY_DOMAINS__:

{
  "outbounds": [
    {
      "tag": "direct",
      "protocol": "freedom",
      "settings": { "domainStrategy": "UseIPv4" },
      "streamSettings": { "sockopt": { "mark": 255 } }
    },
    { "tag": "block", "protocol": "blackhole" },
    __OUTBOUNDS__
  ],
  "routing": {
    "domainStrategy": "IPIfNonMatch",
    "domainMatcher": "hybrid",
    "balancers": [
      {
        "tag": "balancer",
        "selector": ["proxy-"],
        "strategy": { "type": "leastPing" },
        "fallbackTag": "direct"
      }
    ],
    "rules": [
      { "type": "field", "inboundTag": ["tproxy-in"], "port": 53, "network": "udp", "outboundTag": "direct" },
      __USER_PROXY_DOMAINS__
      { "type": "field", "ip": ["geoip:private"], "outboundTag": "direct" },
      { "type": "field", "protocol": ["bittorrent"], "outboundTag": "direct" },
      ...
    ]
  }
}

__OUTBOUNDS__ — сюда подставляется JSON-массив прокси-серверов, собранный из VLESS-ссылок подписки. У меня сейчас 8 серверов от двух разных провайдеров с тегами proxy-govpn-1..2 и proxy-dark-1..6.

__USER_PROXY_DOMAINS__ — сюда подставляется один routing-объект с пользовательскими доменами, прописанными в /etc/xray/user-proxy-domains.conf.

Главный плюс: шаблон обновляется отдельно от подписки и отдельно от моих доменов. Хочу добавить новый geosite в системный список — правлю шаблон, дальше cron пересобирает рабочий конфиг с актуальными outbounds и моими доменами. Ничего не теряется при следующем обновлении подписки.

Ключевой кусок xray-update-safe, делающий обе подстановки:

sed "s|__OUTBOUNDS__|$(cat "$OUT" | sed ':a;N;$!ba;s/\n/\\n/g')|" "$TPL" > "$TMP/config.json.step1"

USER_FRAG=""
USER_FILE="/etc/xray/user-proxy-domains.conf"
if [ -f "$USER_FILE" ]; then
  USER_ENTRIES=$(sed 's/#.*$//' "$USER_FILE" \
    | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
    | grep -v '^$' \
    | awk '/^(full|keyword|regexp|geosite|domain):/ {print; next} {print "domain:" $0}' \
    | sort -u)
  if [ -n "$USER_ENTRIES" ]; then
    DOMS=$(echo "$USER_ENTRIES" | awk '{printf "%s\"%s\"", (NR==1?"":", "), $0}')
    USER_FRAG='{ "type": "field", "domain": ['"$DOMS"'], "balancerTag": "balancer" },'
  fi
fi
sed "s|__USER_PROXY_DOMAINS__|${USER_FRAG}|" "$TMP/config.json.step1" > "$TMP/config.json"

XRAY_LOCATION_ASSET="$ASSET_DIR" "$XRAY_BIN" run -test -c "$TMP/config.json" || {
  log "FAIL: config validation"
  exit 1
}

if cmp -s "$TMP/config.json" "$CFG"; then
  log "config unchanged, skip restart"
  exit 0
fi

cp "$CFG" "$CFG.bak" 2>/dev/null || true
cp "$TMP/config.json" "$CFG"
/etc/init.d/xray-tproxy restart

Здесь три приёма, которые стоит разобрать.

sed ':a;N;$!ba;s/\n/\\n/g' при подстановке outbounds — идиома для замены многострочного текста через sed. :a — метка, N — добавить следующую строку в pattern space, $!ba — пока не последняя, прыгай назад, s/\n/\\n/g — все переводы строк замени на литеральные \n. После такой нормализации sed подставляет одну длинную строку, и многострочный JSON не ломает синтаксис.

xray run -test — валидация конфига до того, как он будет применён. Если в шаблоне опечатка или плейсхолдер не подставился — -test ругается, скрипт выходит, текущий рабочий config.json остаётся нетронутым.

cmp -s перед рестартом — это та оптимизация, которой не было в первой версии. До неё cron бил restart каждые 30 минут вне зависимости от того, изменился ли конфиг. Это 48 рестартов в сутки, и каждый рвёт активные TCP-сессии: SSH, видео, AI-сервисы. Сейчас, если содержимое нового конфига байт-в-байт совпадает с текущим работающим, рестарт пропускается. На стабильной подписке (когда серверы не меняются часами) это даёт 0 рестартов в сутки вместо 48.


Изменение 5: vpn-domains — CLI поверх всей этой машинерии

Шаблон + подстановка — это инфраструктура. Для повседневной работы нужен инструмент, чтобы добавить домен одной командой. Так появился /usr/bin/vpn-domains. Вот часть его документации в заголовке:

#!/bin/sh
# vpn-domains — manage user-defined domains routed through VPN.
#
# Storage: /etc/xray/user-proxy-domains.conf
#   Plain text, one entry per line, '#' for comments.
#   Entries can be:
#     example.com           — exact domain + subdomains (becomes "domain:example.com")
#     domain:example.com    — same explicitly
#     full:foo.bar          — exact match only (no subdomains)
#     keyword:netflix       — substring match
#     regexp:.*ai.*         — regex match (use sparingly, slower)
#     geosite:openai        — geosite category
#
# Usage:
#   vpn-domains list              # show all entries
#   vpn-domains add <domain>      # add and apply
#   vpn-domains rm <domain>       # remove and apply
#   vpn-domains apply             # rebuild config and graceful-reload xray
#   vpn-domains system            # show all built-in (template) proxy domains
#   vpn-domains check <domain>    # check if domain is in any proxy list
#   vpn-domains has <domain>      # quiet check; exit 0 if present, 1 if not

Хранилище — простой текстовый файл /etc/xray/user-proxy-domains.conf, по одной записи на строку:

# User proxy domains - managed by LuCI page.
# One entry per line. '#' starts a comment.
# Format: bare hostname OR <full|keyword|regexp|geosite|domain>:<value>

app.quiver.ai
img2go.com
quiver.ai
unwatermark.ai
www.dreamega.ai
www.iloveimg.com

Префиксы domain:, full:, keyword:, regexp:, geosite: — это нативные типы матчинга Xray. Если префикса нет, скрипт автоматически добавляет domain: (subdomain match).

Файл легко править через SFTP в любимом редакторе, легко синкается через Git между двумя роутерами (у меня дома и на даче), и не зависит от состояния веб-интерфейса.


Изменение 6: LuCI-страница на JS-only стеке

В первой попытке я писал контроллер на Lua, как было принято в LuCI:

/usr/lib/lua/luci/controller/vpn-domains.lua
/usr/lib/lua/luci/view/vpn-domains.htm

Залил, перезагрузил, открываю URL — 404. Лезу на роутер по SSH:

$ ls -la /usr/lib/lua/
ls: /usr/lib/lua/: No such file or directory

Сюрприз. На OpenWrt 25.x Lua-LuCI больше нет. Современная LuCI (luci-base версии 26.x в моей сборке) полностью на JavaScript, исполняется в браузере, бэкенд ходит через ubus к rpcd. Старая Lua-машинерия выкинута, и куча сторонних пакетов в OpenWrt 25.x сломалась — у них как раз те пути, которых больше нет.

Структура файлов JS-only LuCI-приложения — пять файлов:

/usr/share/luci/menu.d/luci-app-vpn-domains.json    меню
/usr/share/rpcd/acl.d/luci-app-vpn-domains.json     ACL для LuCI-приложения
/www/luci-static/resources/view/vpn-domains/main.js страница (JS)
/usr/libexec/rpcd/luci.vpn-domains                  бэкенд (shell)
/usr/share/rpcd/acl.d/luci.vpn-domains.json         ACL для rpcd-плагина

Меню

Регистрирует пункт в Services:

{
  "admin/services/vpn-domains": {
    "title": "VPN Domains",
    "order": 80,
    "action": {
      "type": "view",
      "path": "vpn-domains/main"
    },
    "depends": {
      "acl": [ "luci-app-vpn-domains" ]
    }
  }
}

ACL

Два файла. Первый — ACL LuCI-приложения, говорит, какие методы ubus и какие файлы разрешено дёргать со страницы:

{
  "luci-app-vpn-domains": {
    "description": "Grant access to VPN Domains management",
    "read": {
      "ubus": { "luci.vpn-domains": [ "list", "system" ] },
      "file": {
        "/etc/xray/user-proxy-domains.conf": [ "read" ],
        "/etc/xray/config.template.json": [ "read" ]
      }
    },
    "write": {
      "ubus": { "luci.vpn-domains": [ "save", "apply" ] },
      "file": {
        "/etc/xray/user-proxy-domains.conf": [ "write" ],
        "/usr/bin/vpn-domains": [ "exec" ]
      }
    }
  }
}

Второй — ACL самого rpcd-плагина, декларирует права доступа на уровне ubus-сервиса:

{
  "luci.vpn-domains": {
    "description": "VPN Domains rpcd plugin — file ACL for storage/template",
    "read": {
      "file": {
        "/etc/xray/user-proxy-domains.conf": [ "read" ],
        "/etc/xray/config.template.json": [ "read" ]
      }
    },
    "write": {
      "file": {
        "/etc/xray/user-proxy-domains.conf": [ "write" ],
        "/usr/bin/vpn-domains": [ "exec" ]
      }
    }
  }
}

Это та защита, которой в Lua-LuCI не было: страница не может вызвать произвольную shell-команду. Только то, что явно перечислено в обоих ACL-файлах.

Frontend: main.js

Страница — это AMD-модуль, экспортирующий объект view. Объявляет три RPC-вызова через rpc.declare() и рендерит DOM:

'use strict';
'require view';
'require ui';
'require rpc';
'require dom';

var callList = rpc.declare({
    object: 'luci.vpn-domains',
    method: 'list',
    expect: { entries: [] }
});

var callSystem = rpc.declare({
    object: 'luci.vpn-domains',
    method: 'system',
    expect: { entries: [] }
});

var callSave = rpc.declare({
    object: 'luci.vpn-domains',
    method: 'save',
    params: [ 'entries' ],
    expect: { },
    reject: true
});

return view.extend({
    user_entries:   [],
    work_entries:   [],
    system_entries: [],
    filter_text:    '',

    load: function () {
        return Promise.all([
            callList().catch(function () { return []; }),
            callSystem().catch(function () { return []; })
        ]);
    },

    render: function (data) {
        this.user_entries   = data[0] || [];
        this.work_entries   = this.user_entries.slice();
        this.system_entries = data[1] || [];
        ...
    },

    handleSaveApply: null,
    handleSave: null,
    handleReset: null
});

work_entries — рабочая копия списка, в которой текущая сессия редактирования живёт до сохранения. user_entries — то, что реально записано в файл. Если человек поправил, но не нажал «Сохранить и применить», у него остаётся возможность откатиться через handleRevert.

handleSaveApply: null (плюс handleSave и handleReset) — это отключение дефолтных кнопок LuCI внизу страницы. У нас свои кнопки в шапке, и стандартный «Save & Apply» от LuCI здесь не нужен.

Под render живут отдельные функции renderUserList() и renderSystemList(), которые пересобирают DOM при изменении фильтра — без этого пользователь не сможет быстро искать домен в списке из 200 записей в системной части.

Backend: rpcd-handler

/usr/libexec/rpcd/luci.vpn-domains — это исполняемый shell-скрипт, реализующий протокол rpcd. На него rpcd сначала вызывает с аргументом list, чтобы узнать, какие методы доступны, и с какими параметрами:

case "$1" in
    list)
        echo '{
            "list":   {},
            "system": {},
            "save":   { "entries": [ "" ] },
            "apply":  {}
        }'
        ;;

А затем — с аргументом call <method>, передавая параметры через stdin как JSON. Backend пишет ответ в stdout, опять JSON. Между rpcd и shell-скриптом контракт — текстовые JSON по pipe’ам, ничего больше.

Метод list (читай как «список пользовательских доменов»):

list)
    if [ -f "$USER_FILE" ]; then
        entries=$(sed 's/#.*$//' "$USER_FILE" \
            | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
            | grep -v '^$' \
            | awk 'BEGIN{first=1} {
                if (first) { printf "\"%s\"", $0; first=0 }
                else        { printf ",\"%s\"", $0 }
            }')
    else
        entries=""
    fi
    echo "{\"entries\":[${entries}]}"
    ;;

Парсит файл, выкидывает комментарии и пустые строки, оборачивает каждый домен в кавычки и собирает JSON-массив. Без jq — потому что собрать строки в JSON-массив через awk дешевле, чем шеллить-аут jq.

Метод system — извлекает все доменные паттерны прямо из шаблона config.template.json через regex:

system)
    if [ -f "$TEMPLATE" ]; then
        entries=$(grep -oE '"(domain|full|keyword|regexp|geosite):[^"]+"' "$TEMPLATE" \
            | tr -d '"' \
            | sort -u \
            | awk 'BEGIN{first=1} {
                if (first) { printf "\"%s\"", $0; first=0 }
                else        { printf ",\"%s\"", $0 }
            }')
    fi
    echo "{\"entries\":[${entries}]}"
    ;;

Дедупликация и сортировка — чтобы в UI они шли в предсказуемом порядке.

Метод save — самый интересный, это запись + применение:

save)
    input=$(cat)
    if command -v jq >/dev/null 2>&1; then
        entries=$(echo "$input" | jq -r '.entries[]?' 2>/dev/null)
    else
        entries=$(echo "$input" \
            | sed -n 's/.*"entries"[[:space:]]*:[[:space:]]*\[\(.*\)\].*/\1/p' \
            | tr ',' '\n' \
            | sed 's/^[[:space:]]*"//; s/"[[:space:]]*$//')
    fi

    tmp=$(mktemp)
    {
        echo "# User proxy domains - managed by LuCI page."
        echo "# One entry per line. '#' starts a comment."
        echo ""
        echo "$entries" | awk '
            {
                gsub(/^[[:space:]]+|[[:space:]]+$/, "")
                if ($0 == "" || substr($0,1,1) == "#") next
                if (!match($0, /^[a-z]+:/)) {
                    $0 = tolower($0)
                }
                if (!seen[$0]++) print $0
            }
        ' | sort
    } > "$tmp"

    cp "$tmp" "$USER_FILE"
    chmod 644 "$USER_FILE"
    rm -f "$tmp"

    out=$("$VPN_DOMAINS" apply 2>&1)
    code=$?
    out_esc=$(printf '%s' "$out" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' \
        | awk 'BEGIN{ORS="\\n"} {print}')
    if [ "$code" -eq 0 ]; then
        echo "{\"ok\":true,\"applied_count\":${count},\"exit_code\":${code},\"output\":\"${out_esc}\"}"
    else
        echo "{\"ok\":false,\"applied_count\":${count},\"exit_code\":${code},\"output\":\"${out_esc}\"}"
    fi
    ;;

Здесь несколько особенностей.

jq опционален: если он установлен — парсим вход через него (надёжно), иначе через sed | tr. На моём роутере jq есть, но если его нет — система не падает, использует fallback.

Атомарная запись через mktemp + cp + rm: пока новый файл не готов, старый не трогается. На полпути замены — никакой странной комбинации старых и новых записей.

Нормализация: лоуэркейс для голых хостов (для которых ещё нет prefix), дедупликация через seen[] в awk, сортировка. Файл всегда детерминирован.

Escape вывода в JSON: vpn-domains apply может выдать что угодно, в том числе кавычки и табы. sed-цепочка вместо jq -Rs '@json' — потому что повторюсь, не у всех jq есть.

exit_code — не просто ok: true/false, а ещё и код возврата vpn-domains. Если что-то сломалось при применении конфига — UI показывает реальный shell-вывод, а не «something went wrong».

Что получилось в итоге

Открываешь http://192.168.1.1/cgi-bin/luci/admin/services/vpn-domains, видишь свой список доменов с фильтром и системный список (geosite/domain из шаблона) рядом — чтобы понимать, что уже маршрутизируется через прокси без твоего вмешательства. Добавляешь, удаляешь, нажимаешь «Сохранить и применить» — секунды через 2-3 оно работает.

Главный бенефит — не надо держать в голове, что уже включено в системный список. Видишь его рядом, понимаешь: «о, geosite:openai уже там, quiver.ai — нет, надо добавить».


Изменение 7: второй слой обработки UDP-голоса через NFQUEUE

В первой части UDP-голос в современных мессенджерах работал плохо или не работал вообще. Причина — UDP-WebRTC через TPROXY в моей схеме просто не получался, а многие провайдеры активно фильтруют такой трафик через DPI на сигнатуре пакета. Проксирование UDP через VLESS+Reality поверх TCP — теряется RTT, голос становится неюзабельным.

Решение, к которому пришёл — поставить второй слой обработки, отдельный от Xray, который работает на postrouting hook и обходит DPI через техники проекта nfqws (часть открытого проекта по обходу DPI; ссылка в источниках в конце). Идея простая: исходящие UDP-пакеты с определёнными портами уходят в NFQUEUE, юзерспейс-программа смотрит на них через L7-фильтр, и для подходящих посылает перед каждым настоящим пакетом «фейк» — мусорный пакет, который DPI разбирает первым и теряет след сессии.

Init-скрипт /etc/init.d/nfqws-discord создаёт отдельную nft-таблицу:

setup_nftables() {
    nft delete table $NFT_TABLE 2>/dev/null

    nft -f - << 'NFT'
table inet nfqws_discord {
    chain discord_output {
        type filter hook postrouting priority mangle + 1; policy accept;
        oifname != { "eth0", "pppoe-wan" } return
        meta l4proto != udp return
        udp dport 50000-65535 queue flags bypass to 200
        udp dport 19294-19344 queue flags bypass to 200
    }
}
NFT
}

Тонкости:

priority mangle + 1 — выполняется после базового mangle. Мы хотим увидеть пакет уже после того, как он прошёл через все остальные mangle-правила (включая, потенциально, то самое sockopt mark от freedom outbound). Если бы priority был меньше или равен — могли бы захватить пакет до того, как Xray его маркировал, и логика поломалась.

oifname != { "eth0", "pppoe-wan" } return — обрабатываем только пакеты, уходящие в WAN. Лишние циклы на LAN-трафик не нужны.

queue flags bypass — критичное. bypass означает «если nfqws не запущен или упал — пропусти пакет дальше как есть». Без этого падение nfqws парализует весь UDP-голос: пакеты копились бы в очереди и тихо дропались. С bypass — голос работает напрямую, пусть и без обхода DPI.

queue ... to 200 — номер очереди NFQUEUE, к которой приcоединится юзерспейс-демон.

Сам демон запускается через procd с двумя --new фильтрами (для двух диапазонов портов с разной семантикой):

procd_open_instance "nfqws-discord"
procd_set_param command "$NFQWS_BIN" \
    --qnum=$QNUM \
    --filter-udp=50000-65535 --filter-l7=discord,stun --dpi-desync=fake --dpi-desync-repeats=6 \
    --new \
    --filter-udp=19294-19344 --filter-l7=discord,stun --dpi-desync=fake --dpi-desync-repeats=6
procd_set_param respawn 3600 5 5
procd_close_instance

--filter-l7=discord,stun — встроенные в nfqws L7-сигнатуры. Парсер на лету определяет, что в payload UDP-пакета сидит именно WebRTC/STUN, и применяет обход только к таким — иначе обработка касалась бы всего UDP-трафика на этих портах подряд. На моих ~3500 voice-пакетах в секунду это разница между «процессор тлеет» и «1% CPU usage».

--dpi-desync=fake --dpi-desync-repeats=6 — посылать перед каждым настоящим пакетом 6 фейковых. Шесть — экспериментально подобранное число: меньше — DPI иногда успевает разобрать настоящий пакет, больше — отъедает bandwidth и мощности у некоторых провайдеров.

В watchdog добавлен соответствующий блок:

if ! pidof nfqws >/dev/null; then
    logger -t xray-watchdog "nfqws dead, restarting nfqws-discord"
    /etc/init.d/nfqws-discord restart
fi

if pidof nfqws >/dev/null && ! nft list table inet nfqws_discord >/dev/null 2>&1; then
    logger -t xray-watchdog "nfqws nftables missing, restarting nfqws-discord"
    /etc/init.d/nfqws-discord restart
fi

Если демон жив, но таблицы нет (теоретически возможно, если кто-то её случайно удалил при чём-то) — рестарт. Если демона нет — рестарт. Каждую минуту.

Параллельно UDP-портам из этого диапазона прописан TPROXY-обработчик в основной таблице ip xray, чтобы Xray сам видел и проксировал их (если конкретное приложение использует voice через TCP-порт мессенджера, а не WebRTC). Так что у нас два слоя для UDP-голоса: сначала Xray делает sniff и пытается маршрутизировать, потом если выбрал direct — пакет идёт через построутинг-цепочку с обходом DPI. Двойная страховка.


AdGuard Home: что DNS делает и куда идёт

В первой части AdGuard Home упоминался кратко. Сейчас — про конкретную конфигурацию, на которой я остановился.

Конфиг живёт по двум путям: /etc/adguardhome/AdGuardHome.yaml (overlay, сохраняется между ребутами) и /tmp/adguardhome/AdGuardHome.yaml (рабочая копия в tmpfs, чтобы AGH не писал статистику и логи на NAND). Init-скрипт копирует overlay → tmpfs при старте и tmpfs → overlay при остановке (если изменилась).

Upstream DNS:

upstream_dns:
    - https://1.1.1.1/dns-query
    - https://8.8.8.8/dns-query
    - 9.9.9.10
    - 149.112.112.10
    - 2620:fe::10
    - 2620:fe::fe:10
    - version.bind
    - id.server
    - hostname.bind
    - 127.0.0.0/8
    - ::1/128

bootstrap_dns:
    - 9.9.9.10
    - 149.112.112.10
    - 2620:fe::10
    - 2620:fe::fe:10

Cloudflare и Google как DoH-апстримы — это DNS поверх HTTPS. Quad9 (9.9.9.10) как plain UDP/IPv4 — это fallback на случай, если HTTPS-апстримы недоступны.

Что важно для архитектуры: DoH — это просто HTTPS-трафик к 1.1.1.1. Он попадает на роутер как обычные исходящие соединения. Идёт через nft TPROXY → Xray → routing → 1.1.1.1 не в geoip:ru, не в direct-списке, значит matching по default-rule → balancer → через защищённый канал.

То есть DNS-запросы устройств в LAN превращаются в зашифрованные HTTPS-запросы, которые сами идут через защищённый канал. Plaintext DNS на uplink-интерфейсе — нет. Это ключевое свойство, ради которого AGH стоит здесь именно в такой связке.

bootstrap_dns — это резолверы, через которые AGH резолвит сами DoH-апстримы. Если бы там стояли только DoH-адреса, был бы chicken-and-egg. Quad9 plain работает в обход AGH-цепочки и обеспечивает resolve 1.1.1.1/8.8.8.8 в IP-адреса.


Watchdog: 7 проверок, одна цель

xray-watchdog крутится cron’ом каждую минуту. Его полная логика:

if [ -f "$LOCKFILE" ]; then
    [ -n "$(find "$LOCKFILE" -mmin +10 2>/dev/null)" ] && rm -f "$LOCKFILE"
    exit 0
fi

if ! ip route | grep -q "^default"; then
    logger -t xray-watchdog "No default route, restarting network"
    nft delete table ip xray 2>/dev/null
    /etc/init.d/network restart
    sleep 15
    exit 0
fi

if ! ping -c 1 -W 3 1.1.1.1 >/dev/null 2>&1; then
    if ! ping -c 1 -W 3 8.8.8.8 >/dev/null 2>&1; then
        logger -t xray-watchdog "No connectivity, removing nftables"
        nft delete table ip xray 2>/dev/null
        while ip rule del fwmark 1 table 100 2>/dev/null; do :; done
        exit 0
    fi
fi

if ! pidof xray >/dev/null; then
    logger -t xray-watchdog "Xray dead, cleaning and restarting"
    nft delete table ip xray 2>/dev/null
    while ip rule del fwmark 1 table 100 2>/dev/null; do :; done
    /etc/init.d/xray-tproxy start
    exit 0
fi

if ! nft list table ip xray >/dev/null 2>&1; then
    logger -t xray-watchdog "nftables missing, restarting xray-tproxy"
    /etc/init.d/xray-tproxy restart
fi

if ! pidof nfqws >/dev/null; then
    /etc/init.d/nfqws-discord restart
fi

if pidof nfqws >/dev/null && ! nft list table inet nfqws_discord >/dev/null 2>&1; then
    /etc/init.d/nfqws-discord restart
fi

rm -rf /tmp/geo-update /tmp/xray-geodata 2>/dev/null

Главная нетривиальная штука — ключевой принцип «нет интернета — снести TPROXY». Если ping и до 1.1.1.1, и до 8.8.8.8 не проходит, watchdog убирает nft-таблицу и policy routing. Без этого роутер становится «чёрной дырой» в LAN: пакеты упорно отправляются в TPROXY, попадают в Xray, который не может их доставить, дропаются. С точки зрения клиента — таймаут на каждом TCP-соединении. После убирания таблицы клиенты получают честный «no route to host» и могут показать пользователю осмысленное сообщение об ошибке.

После восстановления интернета на следующий тик watchdog видит, что Xray жив, но nftables нет — и поднимает их обратно через restart.


Тонкости с балансировкой, которых я в первой части не предусмотрел

Это не изменение в коде, а наблюдение за время эксплуатации. Но оно влияет на то, как я живу с системой.

В моём пуле сейчас 8 серверов от двух провайдеров. Observatory из Xray честно пингует все 8 раз в 60 секунд (значение probeInterval в моём конфиге). Все видны как is alive. Один из серверов — proxy-govpn-1 — стабильно даёт минимальный пинг по https://www.google.com/generate_204, и leastPing фиксируется на нём.

Через него идёт весь мой трафик до следующего цикла observatory. И вот тут начинается интересное: некоторые сервисы через proxy-govpn-1 периодически отвечают 504, у других — Cloudflare-капча: «обнаружена подозрительная активность». У провайдера выходной IP в каком-то списке, который Cloudflare считает «toxic», и не пропускает запросы. Если вручную переключить балансер на любой proxy-dark-* — всё открывается мгновенно.

leastPing — оптимальная стратегия по latency, но она ничего не знает про репутацию выходного IP. Решение, на котором живу, — оставить govpn-серверы в пуле, но разнести через явные правила routing’а: для AI-сервисов (которые особенно страдают от антифрод-систем) фиксировать outboundTag: "proxy-dark-3" с конкретным сервером и хорошей репутацией IP, для остального — balancer.

Это, конечно, нарушает идею balancer’а как «здесь ничего не надо знать про конкретные сервера». Но эмпирически это сейчас единственное, что работает стабильно.

И второй момент по observatory: в шаблоне написано probeInterval: 60s. Это часто. На каждом тике балансер может переключиться на другой сервер с минимальной задержкой, и активные TCP-соединения, которые шли через старый сервер, будут принудительно разорваны. Я экспериментировал с 300s — стабильность активных сессий сильно лучше, но обнаружение деградировавших серверов сильно хуже. Сейчас остановился на 60s + skip-restart-if-unchanged как двух противонаправленных оптимизациях, которые балансируют друг друга.


Что было сделано по фидбеку из комментариев к первой статье

Первая часть набрала ~100 комментариев, и значительная часть текущих изменений — прямой ответ на разбор от читателей. Спасибо всем, кто конструктивно ткнул в дыры. Конкретно:

marus_space — переворот логики маршрутизации. Главное архитектурное изменение, описанное в самом верху статьи. Было proxy-by-default → стало direct-by-default + явный whitelist через balancer. Это в первую очередь защита от утечки внешнего IP перед российскими антифрод-системами, на которую он указал.

0ka — поддержка UDP. Тоже сделано: network: "tcp,udp" в tproxy-in, sniffing включает QUIC, в nft добавлены TPROXY-правила для UDP voice-диапазонов. QUIC drop оставлен как было, ровно по тем причинам, которые 0ka сам и сформулировал (двойной congestion control, лишний CPU на шифрование).

savant_a — проблема с overlay flash. Решено через распаковку gz-бинарников из overlay в tmpfs при старте — описано в «Изменении 1». Без модификации U-Boot, чтобы не лезть в загрузчик.

andrex77 — упоминание U-Boot для расширения flash. Это альтернативный путь (даёт ~95 МБ overlay вместо 44 МБ), но я его не пошёл — в моей схеме сжатые бинарники в overlay + распаковка в tmpfs работают чисто и не требуют переразметки. Кто хочет ставить тяжёлый стек из штатных пакетов и не мучиться — это рабочая альтернатива.

mejor-correo — HWID для подписок. Поддерживается в скрипте обновления через заголовок x-hwid. Если провайдер требует — конкретные значения подставляются в curl при fetch’е подписки.

Aleksei_7bc, electrodummy, zbot — антифрод-пробинг от приложений (МАХ, маркетплейсы, банки). Это и есть основной мотиватор переворота логики. С direct-by-default любая JS-проба на маркетплейсе или прозвон IP-чекера приложением видит реальный российский IP. Полностью от пробинга это не защищает (в комментариях 0xBADC0FFE справедливо заметил, что приложение может имитировать браузер и зайти на web.telegram.org, после чего получит VPN-IP), но снижает площадь атаки на порядок.

activa — выкладка на GitLab/etc. Я пока не выкладывал. Сначала хотел довести систему до стабильного состояния — текущая версия и есть результат этого «доведения». Дальше посмотрю.

Что НЕ изменилось, хотя в комментариях советовали:

  • IPv6. В Xray стоит "queryStrategy": "UseIPv4", в direct outbound — "domainStrategy": "UseIPv4". То есть Xray резолвит и работает только в IPv4. Включение IPv6 — отдельная задача с реальным риском leak’ов, и я её сознательно отложил, как и в первой статье. Mingun и activa справедливо спрашивали про это — пока не дошли руки.

  • fakedns. SantaClaus16 советовал перейти на fakedns как современный стандарт. У меня всё ещё AdGuard Home + DoH-апстримы с Quad9 plain как fallback. Это тоже компромисс ради простоты — fakedns даёт более точный sniffing для UDP, но требует переписать всю DNS-цепочку. Возможно, в третьей части.

  • Свои geosite вместо чужих dat. Тот же SantaClaus16 ткнул в это. Согласен — но трудозатраты на поддержание собственных списков geosite:openai, geosite:youtube и десятка других, которые runetfreedom/v2fly держат community-усилиями, не окупаются для домашнего шлюза. Использую чужие, проверяю sha256 при обновлении.

  • gRPC/WS как fallback transport. 0ka упоминал. Не реализовано, потому что в моей схеме TCP+Reality пока хорошо проходит. Если упадёт — буду добавлять.


Сравнение с готовыми решениями (Podkop, PassWall2)

В комментариях к первой статье несколько раз звучал справедливый вопрос: «зачем это всё, если есть Podkop / PassWall2 / V2RayA, которые делают то же самое одной кнопкой?» Отвечаю развёрнуто, потому что вопрос важный и многим читателям проще выбрать готовое, чем повторять мою схему вручную.

Podkop — пакет для OpenWrt от itdoginfo. Объединяет Sing-Box и подкоп-маршрутизацию по доменам/geoip в LuCI-приложение. Ставится в две команды, настраивается мышкой, есть категории, поддержка подписок, автоматическое обновление geo. Активно обновляется, имеет большое сообщество.

PassWall2 — OpenWrt-пакет от xiaorouji. По сути — фронтенд к Xray/Sing-Box/Hysteria/V2Ray с вебом для настройки. Умеет: transparent proxy, smart routing по доменам и geo, DNS control с DoH/DoT, load balancing, subscription support, node testing с failover, балансировку. То есть в нём из коробки есть почти всё, что я собрал руками.

V2RayA — упоминал nikulin_krd. Web-UI для V2Ray/Xray с настройкой через браузер, проще всего для одного устройства, но для роутерной transparent-схемы менее популярен.

Сравнение по ключевым параметрам:

Моя схема (часть 2)

Podkop

PassWall2

Установка

руками, 30+ минут

opkg install, ~5 мин

opkg install, ~5 мин

LuCI-настройка

только VPN Domains

полностью

полностью

Прозрачность работы

максимальная (видно весь код)

средняя (LuCI + бинарь)

средняя (LuCI + бинарь)

Поддержка обновлений

моя

сообщество itdoginfo

сообщество xiaorouji

Кастомизация под себя

любая

в пределах LuCI

в пределах LuCI

Обход DPI для UDP-голоса

да (nfqws отдельно)

нет (своими средствами)

нет (своими средствами)

Подходит для overlay 44 МБ

да (gz в overlay)

впритык

не помещается без U-Boot

Поддержка geosite:

да

да

да

Несколько подписок одновременно

да

да

да

Когда выбрать готовое решение, а не мою схему:

  • Если цель — подключить и забыть. Podkop / PassWall2 ставятся за 10 минут, имеют большое сообщество, обновляются автором. У меня — кастомные init-скрипты, которые я обновляю сам по мере необходимости.

  • Если на роутере не нужен DPI-обход для UDP-голоса. Это ниша nfqws, и в готовых решениях её нет — там UDP либо проксируется (с потерей RTT), либо игнорируется.

  • Если вы не хотите разбираться, что такое TPROXY, policy routing и nftables. В моей схеме без этого понимания не починить, если что-то ляжет.

Когда имеет смысл моя схема:

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

  • Когда стандартные пакеты не помещаются в overlay (44 МБ Cudy TR3000), и не хочется копаться в U-Boot.

  • Когда нужна одновременно работа Xray для сплит-роутинга и nfqws для DPI-обхода UDP-голоса. В готовых пакетах это две разных установки (PassWall2 + zapret отдельно), которые нужно вручную скоординировать.

  • Когда хочется, чтобы пользовательские proxy-домены управлялись через одну свою LuCI-страницу, а не закопаны в десятке вкладок настроек большого пакета.

В целом: если бы мне нужно было настроить роутер у мамы — я бы поставил Podkop. Для своего домашнего стека я выбираю писать руками — потому что когда что-то ломается (а оно периодически ломается, observatory залипает на медленный сервер, провайдер меняет DPI-сигнатуры), мне быстрее починить свою систему, чем разбираться, как Podkop у себя внутри что-то делает.


Что не работает / что не сделал

Несколько идей, которые либо не получились, либо отбросил намеренно. Полезно для контекста — что в системе сознательно отсутствует.

Auto-detection доменов. Логика «если устройство не смогло подключиться — добавь его в proxy-список». Звучит привлекательно, но false-positive фабрика: одна неудачная HTTPS-сессия к российскому сайту с протухшими сертификатами — и домен уезжает в proxy. Дальше каскадные эффекты, которые тяжело отлаживать.

SIGUSR1 как полноценный graceful reload. В заголовке vpn-domains написано «reloads xray with SIGUSR1 if supported». На практике в xray-update-safe я делаю обычный restart, потому что не до конца уверен, что SIGUSR1-handler в текущей сборке Xray-core (xray-core apk-пакет, который сейчас стоит) корректно перечитывает все секции. Эксперименты были, но без длительного бенчмарка отдать живой трафик SIGUSR1-релоаду в проде — слишком рискованно. Сейчас живу с тем, что restart нужен только после cmp -s-проверки несовпадения, то есть в сутки случается нечасто.

Per-device routing. Чтобы конкретный MAC-адрес ходил через proxy-dark-3, а другой — direct. У Xray это можно сделать через отдельный TPROXY-инбаунд для отдельной подсети, но это усложняет nft-цепочку и не уверен, что оно того стоит. Все устройства живут в одной br-lan 192.168.1.0/24.

Observatory с health-check на content уровне. Идея: подменить probeURL с /generate_204 на что-то, что отдаёт 403 на «toxic IP» — тогда плохие серверы автоматически выпадут из пула. Не сделал, потому что добавляется зависимость от внешнего сервиса и риск полного отказа балансера при недоступности этого сервиса.

Дашборд со статистикой. Сколько байт ушло через какой outbound, какой домен качается чаще — есть в логах AGH на :3000. Дублировать в LuCI ради красоты — больше кода, больше багов, ноль практической пользы.


Итоговая структура файлов

/etc/xray/
  config.template.json          шаблон с плейсхолдерами
  config.json                   рабочий конфиг (regenerated)
  config.json.bak               бэкап
  user-proxy-domains.conf       пользовательские домены
  xray.gz, nfqws.gz             бинарники (overlay-сжатые)
  geoip.dat.gz                  geoip-база

/etc/adguardhome/
  AdGuardHome.yaml              конфиг AGH
  adguardhome.gz                бинарник

/usr/bin/
  vpn-domains                   CLI: add/rm/list/system/check/apply
  xray-update-safe              cron */30, обновление подписки + ребилд
  xray-watchdog                 cron * * * * *, мониторинг
  xray-geo-update               cron 30 4 * * 0, geo-данные

/etc/init.d/
  xray-tproxy                   основной init с self-bootstrap
  nfqws-discord                 init для UDP DPI bypass
  adguardhome                   init AGH

/usr/share/luci/menu.d/
  luci-app-vpn-domains.json     пункт меню

/usr/share/rpcd/acl.d/
  luci-app-vpn-domains.json     ACL для LuCI-app
  luci.vpn-domains.json         ACL для rpcd-плагина

/www/luci-static/resources/view/vpn-domains/
  main.js                       JS-страница (16314 байт, 435 строк)

/usr/libexec/rpcd/
  luci.vpn-domains              rpcd-handler (4439 байт, 145 строк)

/etc/hotplug.d/net/
  30-eth1-stabilize             фикс для залипания eth1

Что

Чем триггерится

Подписка обновилась

cron xray-update-safe, каждые 30 мин

Я добавил домен через CLI

vpn-domains add ...

Я добавил домен через LuCI

rpcd → vpn-domains apply

Geoip-файлы устарели

cron xray-geo-update, воскресенье 4:30

Xray упал / nftables пропал

cron xray-watchdog, каждую минуту

nfqws упал / nft пропал

тот же xray-watchdog

Холодный старт без geo

self-bootstrap внутри xray-tproxy.init

Каждое из этих событий ведёт к одной и той же машинке: шаблон + outbounds + user-фрагмент → новый config.json → валидация через xray run -test → cmp с текущим → restart только если изменился. Один путь, без побочных эффектов.

В первой части я писал «настроить нужно один раз, обновить — в одном месте». На самом деле в одном месте оказалось несколько разных мест с разной семантикой. Шаблон, подписка, пользовательские домены — это три ортогональные сущности, и каждая хочет свой жизненный цикл. Вторая часть в основном про то, как развести их так, чтобы они не мешали друг другу, и поверх — добавить тонкую LuCI-страницу для повседневной работы.

Если у вас были интересные грабли при работе со схожей архитектурой — особенно по части observatory, балансировки или перехода на JS-only LuCI в OpenWrt 25.x — напишите в комментариях. Хочется заранее узнать о следующих засадах раньше, чем влетишь в них на боевом трафике.


Полезные источники

Документация Xray и протоколов:

OpenWrt 25.x и JS-only LuCI:

TPROXY и nftables:

DPI bypass и UDP:

Geodata и сплит-роутинг:

AdGuard Home:

Комментарии (14)


  1. max9
    08.05.2026 09:50

    работа конечно проделана коллосальная, но зря. очень много работы руками, как вы уже упомянули V2RayA ставится в 1 клик, без половой активности в json конфигах


    1. ShyDamn Автор
      08.05.2026 09:50

      Да, готовое решение тут правда проще, но у меня это целиком собственный кастомный стек, где я полностью контролирую все процессы и знаю, где что находится


    1. dimirion
      08.05.2026 09:50

      Ага... Только последнее обновление не работает.
      А так же постоянно тыкается в китайские сервера, почти на любой настройке(((
      OpenWrt 25.12.2
      v2raya 2.2.7.5


      1. ShyDamn Автор
        08.05.2026 09:50

        Ахах, вот поэтому я и люблю свой стек руками :)


      1. max9
        08.05.2026 09:50

        работает. и 12.2 и 12.3. и никуда не тычется. сохраняли конфиг при sysupgarde? он же трется


        1. dimirion
          08.05.2026 09:50

          Там какая-то проблема с dns и их разрешении

          Несколько раз настраивал как на openwrt 24 ииии, оно не заработало.

          На OpenWrt 24 вообще установить сейчас не получится, если пзу меньше 256МБ, там установка v2rayA занимает 51МБ вместо 26МБ.

          На 25 версии положенные 26 МБ.


  1. denbog1
    08.05.2026 09:50

    Или же поставить passwall2, который очень гибко настраивается, пожалуй самый мощный VPN клиент на openwrt.


    1. ShyDamn Автор
      08.05.2026 09:50

      Это тоже вариант, но, как я говорил ранее, я делал систему под себя, максимально прозрачной в собственном использовании


  1. denbog1
    08.05.2026 09:50

    Вообще, конечно, работа проведена титаническая, но мне кажется, это больше как проект хобби, чем гайд для действия. 95% пользователям будет достаточно passwall2/podkop Представляю сколько усилий и времени по поиску багов, дополнительных корректировок )

    Ваши аргументы по поводу direct-by-default понятны, но в реальной работе, гораздо комфортнее и стабильнее будет proxy-by-default с грамотными правилами и хорошим источником геобаз. Я в последнее время стал использовать легковесный https://github.com/hydraponique/roscomvpn-routing

    Очень много сайтов и приложений, которые не заблокированы вроде бы в РФ, но имеют ограничения для пользователей из РФ. Также РУ сегмент работает очень нестабильно и непредсказуемо, смотрите например последнюю новость как частично недоступен GitHub из РФ. Или, например, у меня неоднократно возникает ситуация, когда я не могу зайти в Сбер или Т-банк, а с нидерландским VPN они открываются :)

    А в случае, если реально IP вашего VPN засветится и заблокируется, что для личных серверов пока что единичные случаи, то гораздо проще взять новый сервак за 500 рублей и накатить на него готовую настройку. Ну или заморочиться с двух-трехсегментным мостом.


    1. ShyDamn Автор
      08.05.2026 09:50

      Согласен, это больше хобби :)
      А direct-by-default для меня сейчас просто практичнее: меньше сюрпризов с антифродом и российскими сервисами.


    1. GlobalPenetrator
      08.05.2026 09:50

      А в случае, если реально IP вашего VPN засветится и заблокируется, что для личных серверов пока что единичные случаи

      Очень интересное заявление про единичные случаи...


  1. rlda
    08.05.2026 09:50

    xiaorouji закрыл/почистил свой акк - соответственно ссылка на passwall2 не рабочая. на https://deepwiki.com/xiaorouji/openwrt-passwall2 пока полный гайд остался


    1. ShyDamn Автор
      08.05.2026 09:50

      Спасибо!