Прозрачный прокси-шлюз на роутере, часть 2: шаблонный конфиг, LuCI-страница и обход DPI для UDP-голоса

Страницы:  1

Ответить
 

Professor Seleznov


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