Дисклеймер. Материал — научно-техническое описание администрирования собственной сетевой инфраструктуры на базе 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.gz → gunzip -c → /tmp/component-bin → chmod +x → procd_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", вdirectoutbound —"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+ минут |
|
|
LuCI-настройка | только VPN Domains | полностью | полностью |
Прозрачность работы | максимальная (видно весь код) | средняя (LuCI + бинарь) | средняя (LuCI + бинарь) |
Поддержка обновлений | моя | сообщество itdoginfo | сообщество xiaorouji |
Кастомизация под себя | любая | в пределах LuCI | в пределах LuCI |
Обход DPI для UDP-голоса | да (nfqws отдельно) | нет (своими средствами) | нет (своими средствами) |
Подходит для overlay 44 МБ | да (gz в overlay) | впритык | не помещается без U-Boot |
Поддержка | да | да | да |
Несколько подписок одновременно | да | да | да |
Когда выбрать готовое решение, а не мою схему:
Если цель — подключить и забыть. 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 |
Я добавил домен через CLI |
|
Я добавил домен через LuCI | rpcd → |
Geoip-файлы устарели | cron |
Xray упал / nftables пропал | cron |
nfqws упал / nft пропал | тот же |
Холодный старт без geo | self-bootstrap внутри |
Каждое из этих событий ведёт к одной и той же машинке: шаблон + outbounds + user-фрагмент → новый config.json → валидация через xray run -test → cmp с текущим → restart только если изменился. Один путь, без побочных эффектов.
В первой части я писал «настроить нужно один раз, обновить — в одном месте». На самом деле в одном месте оказалось несколько разных мест с разной семантикой. Шаблон, подписка, пользовательские домены — это три ортогональные сущности, и каждая хочет свой жизненный цикл. Вторая часть в основном про то, как развести их так, чтобы они не мешали друг другу, и поверх — добавить тонкую LuCI-страницу для повседневной работы.
Если у вас были интересные грабли при работе со схожей архитектурой — особенно по части observatory, балансировки или перехода на JS-only LuCI в OpenWrt 25.x — напишите в комментариях. Хочется заранее узнать о следующих засадах раньше, чем влетишь в них на боевом трафике.
Полезные источники
Документация Xray и протоколов:
Xray-core официальный репозиторий — исходники, релизы, документация по конфигу
Xray-core docs (v2fly format) — описание полей
routing,observatory,balancerVLESS protocol specification — формальный разбор VLESS+Reality
REALITY: новый протокол маскировки — описание Reality-handshake
XTLS-Vision flow control — почему Vision минимизирует двойное шифрование
OpenWrt 25.x и JS-only LuCI:
OpenWrt 25.x Release Notes — переход на apk и luci-base 26.x
LuCI JS API guide (developer) — разработка JS-only приложений
LuCI JavaScript reference — справочник по
view,rpc,ui,domrpcd manpage — протокол rpcd, ACL, регистрация плагинов
Пример JS-only LuCI приложения — как сделано в основной ветке
TPROXY и nftables:
nftables wiki — TPROXY — официальный wiki
TPROXY kernel docs — что такое TPROXY и зачем нужны fwmark + policy routing
Xray + TPROXY на nftables (пример) — официальные примеры
DPI bypass и UDP:
Проект zapret (bol-van/zapret) — основа nfqws, теории DPI desync, репозиторий
NFQUEUE / libnetfilter-queue — kernel-API для перехвата пакетов в userspace
WebRTC и UDP-голос — RFC 8825 — общий контекст по WebRTC
Geodata и сплит-роутинг:
v2fly/domain-list-community — исходники geosite-категорий
runetfreedom/russia-v2ray-rules-dat — geo-данные с категорией
geosite:ru-available-only-inside
AdGuard Home:
AdGuard Home wiki — DoH/DoT настройка — настройка зашифрованных uplink
DoH / RFC 8484 — DNS over HTTPS
