Задача и ловушка
Часть доменов должна идти напрямую — мимо VPN, с реального IP клиента, — а остальное через exit-ноду. В Xray это делается routing-правилами с outboundTag: direct. Частая ловушка: список DIRECT-доменов идеален, а конкретные сервисы всё равно ломаются или видят «чужую» гео. Причина в том, что DNS-резолв и routing в Xray — два разных уровня, и один может тихо утечь в туннель, пока другой настроен верно.
Где живёт split-tunneling
В централизованном шаблоне подписки (формат XRAY_JSON), не в серверном профиле ноды. Панель добавляет юзерские proxy-outbounds в начало массива; в шаблоне статически лежат только direct (freedom) и block (blackhole). Правила:
"routing": {
"domainStrategy": "AsIs",
"rules": [
{ "type": "field", "protocol": ["bittorrent"], "outboundTag": "block" },
{ "type": "field", "ip": ["geoip:private"], "outboundTag": "direct" },
{ "type": "field", "domain": ["domain:bank.example", "full:portal.example", "regexp:.*\\.gov\\.example$"], "outboundTag": "direct" }
]
}
Префиксы доменов: domain:x.tld — suffix-match (*.x.tld плюс сам домен), full: — точное совпадение, regexp: — регулярка.
Ключевой урок: routing по домену ≠ DNS через нужный резолвер
Даже при верном DIRECT-правиле Xray внутри клиента резолвит имена через свой dns.servers — например, публичный резолвер. Этот DNS-запрос уходит через туннель на exit-ноду в другой стране. Локальный сервис видит DNS-запрос с чужого IP и не отвечает, хотя трафик по routing помечен DIRECT. Чтобы DNS резолвился локально, нужны сразу несколько настроек:
routing.domainStrategy: AsIs— Xray матчит по строке домена и сам не резолвит;direct.settings.domainStrategy: UseIP— freedom-outbound резолвит сам, через системный DNS ОС клиента, запрос выходит с реального IP;dns.serversс локальными адресами иskipFallback: trueдля нужных доменов — DNS мимо туннеля;sniffing: {enabled: true, destOverride: ["http","tls","quic"], routeOnly: true}на inbounds — SNI читается для матча, но не подменяет destination (важно для QUIC/ECH).
Максимальная «прозрачность» — DNS через системный резолвер: в dns.servers[0].address поставить localhost. Xray делегирует резолв системному getaddrinfo(), то есть DNS ОС клиента. Тогда ASN DNS-резолвера совпадает с ASN клиента, и приложения с антифродом по DNS-ASN не видят рассогласования. port для localhost смысла не имеет — удалить.
Подводные камни
- Правки шаблона применяются только через PATCH в API панели. Прямой UPDATE в БД проходит, но в выдаче остаётся старая версия — backend кэширует шаблон в памяти, и кэш сбрасывает только API-запрос.
- Разные клиенты — разный формат routing. Полный split едет только в XRAY_JSON (Happ и совместимые). Шаблоны Clash/Mihomo/sing-box требуют своего синтаксиса и по умолчанию пустые — весь трафик там пойдёт в туннель. Заполнять отдельно.
- base64-формат подписки не передаёт DNS-секцию — «прозрачность» работает, только когда подписка отдаётся как JSON.
- Порты в routing — это destination-порты внутри туннеля (BitTorrent-пиры, SMTP, UPnP), не порты подключения к нодам. Не путать уровни.
- Дефолтный шаблон панели не пустой. В предустановленном шаблоне уже лежат унаследованные правила — локальные подсети мимо туннеля, спецобработка BitTorrent. При заливке своего шаблона явно проверьте, что унаследованное не противоречит ожидаемому: в одном случае BitTorrent по умолчанию уходил прямым выходом вместо блокировки.
bittorrent → blockчерез protocol-rule срабатывает только при включённом sniffing; порт-rule на 6881/51413 — страховка на случай его промаха.
Проверка результата
После каждого изменения — двойной тест в клиенте: локальный сервис (например, поисковик региона) должен показывать реальный IP, а зарубежный — IP exit-ноды. Одна проверка покрывает оба уровня сразу: и routing, и DNS.
Вывод. Split-tunneling в Xray — два независимых уровня: routing (куда слать трафик) и DNS (где резолвить имя). Можно собрать идеальный список DIRECT-доменов и всё равно получить обрыв, если DNS про эти домены уходит через туннель. Лечится связкой domainStrategy AsIs + UseIP на direct + локальные dns.servers (или localhost) + sniffing routeOnly.