Дисклеймер. Материал — научно-техническое описание администрирования собственной сетевой инфраструктуры на базе 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: