🧭 Маршрутизация в Xray: как трафик распределяется по outbound

О чём заметка

Разбор модуля маршрутизации (routing) в Xray-core — механизма, который для каждого соединения решает, через какой исходящий канал (outbound) его отправить: напрямую, через прокси или заблокировать. Это то, что на практике позволяет открывать российские сайты мимо туннеля, а заблокированные — через сервер (см. практику раздельной маршрутизации). Здесь — устройство правил и стратегий, сверенное с официальной документацией и с чтением исходного кода app/router/ и app/dispatcher/. Про сами протоколы, поверх которых всё это работает — VLESS, REALITY, Vision.

TL;DR

  • Маршрутизация отправляет входящий трафик в разные outbound по правилам. Типичная задача — разделить внутренний и внешний трафик (.ru напрямую, заблокированное через прокси).
  • Правила проверяются сверху вниз, первое сработавшее выигрывает (first match wins). Если не сработало ни одно — трафик идёт в outbound по умолчанию.
  • Внутри одного правила все условия соединяются логическим И (AND); внутри одного условия (список доменов, IP, портов) — ИЛИ (OR).
  • domainStrategy решает, резолвить ли домен в IP для сопоставления: AsIs (не резолвить), IPIfNonMatch (резолвить, если правила не сработали), IPOnDemand (резолвить сразу).
  • Есть встроенные списки: geosite: (домены, файл geosite.dat) и geoip: (IP-диапазоны стран, файл geoip.dat) — например geoip:cn, geoip:private, geosite:category-ads-all.
  • Балансировщики распределяют трафик между несколькими outbound по стратегиям random/roundRobin/leastPing/leastLoad.
  • Важно: резолв домена для маршрутизации не меняет фактический адрес соединения — цель остаётся исходной.

Общая картина: как соединение проходит через маршрутизацию

Когда приходит соединение, диспетчер (app/dispatcher) делает так:

  1. Определяет цель (адрес и порт) и, при включённом sniffing, «подсматривает» первые байты, чтобы распознать протокол (TLS/HTTP/QUIC/BitTorrent) и извлечь домен из SNI/Host.
  2. Собирает контекст маршрутизации — целевой адрес, домен, тег входящего канала (inbound), источник, тип сети.
  3. Вызывает роутер: Router.PickRoute() перебирает правила сверху вниз и возвращает первое подходящее.
  4. Правило указывает outboundTag (или балансировщик) — трафик уходит в этот исходящий канал.

Проще говоря: маршрутизатор — это диспетчер на развилке. Он смотрит на каждое соединение, сверяет его со списком правил по порядку и на первом же совпадении направляет по нужной «дороге» (outbound). Не совпало ни с чем — едет по дороге по умолчанию.

Куда идёт трафик, если правила не сработали

Если ни одно правило не совпало, соединение уходит в outbound по умолчанию — на практике это первый исходящий канал в конфиге. Поэтому распространённый приём — поставить первым нужный дефолт (например прямое соединение или основной прокси), либо в самом конце правил добавить «ловушку» "network": "tcp,udp" без других условий, которая ловит вообще всё оставшееся и явно задаёт назначение.

RoutingObject: структура

Секция routing в конфиге состоит из трёх частей:

{
  "routing": {
    "domainStrategy": "AsIs",
    "rules": [],
    "balancers": []
  }
}
  • domainStrategy — стратегия разрешения доменов (см. ниже).
  • rules — массив правил, проверяются по порядку.
  • balancers — конфигурации балансировщиков (опционально).

domainStrategy: резолвить домен в IP или нет

Это, пожалуй, самая недопонятая настройка. Она определяет, разрешает ли маршрутизатор доменное имя в IP-адрес ради сопоставления с IP-правилами (geoip: и т.п.). Три значения:

  • AsIs (по умолчанию) — никакого резолва. Работает только с доменом (из цели или из sniffing). IP-правила для доменной цели не сработают, потому что IP просто неоткуда взять.
  • IPIfNonMatch — сначала правила прогоняются как есть (по домену). Если ни одно не совпало, домен резолвится в IP, и весь список правил прогоняется заново — уже с IP. Это даёт доменным правилам приоритет, а IP-правила служат «вторым проходом».
  • IPOnDemand — домен резолвится в IP сразу, ещё до первого прохода, и IP доступен всем правилам с самого начала.

Разрешение ленивое — чтобы не тормозить

Даже в IPOnDemand фактический DNS-запрос не делается заранее: он откладывается до первого правила с IP-условием, которое реально запросит IP (в коде — ResolvableContext.GetTargetIPs, результат кешируется). Если раньше сработало доменное или другое правило — резолв вообще не понадобится, и задержки не будет. Результат содержит и IPv4, и IPv6; сузить семейство можно через queryStrategy встроенного DNS.

Резолв НЕ меняет реальный адрес соединения

Разрешение домена в IP используется только для сопоставления IP-правил. Фактическая цель запроса остаётся исходной — домен уходит на выбранный outbound и резолвится уже там (транспортом или сервером). Это подтверждается кодом: маршрутизатор держит IP в отдельном кеше поверх контекста и никогда не переписывает целевой адрес.

Отдельно: при включённых sniffing + routeOnly маршрутизатор видит и IP, и домен, распознанный из трафика. Домен из sniffing имеет приоритет над исходным целевым адресом — и при резолве, и при сопоставлении.

RuleObject: правило и его условия

Правило описывает набор условий и цель (outboundTag или balancerTag).

Все условия внутри правила — логическое И

Если в одном правиле указано несколько условий (например domain и port), они должны выполниться одновременно, иначе правило не срабатывает. А вот внутри одного условия (список доменов, список IP, список портов) действует ИЛИ — достаточно совпадения любого элемента.

Условие по домену (domain)

Список шаблонов домена. Типы (по префиксу):

ПрефиксТипПримерМатчит
full:Полное совпадениеfull:xray.comтолько xray.com
domain:Домен и поддомены (рекомендуется)domain:xray.comxray.com, www.xray.com, но не wxray.com
keyword:Подстрокаkeyword:sina.comsina.com, sina.com.cn, www.sina.com
regexp:Регулярное выражениеregexp:\.goo.*\.com$www.google.com, но не google.com
dotless:Домен без точекdotless:pc-pc-alice (для NetBIOS-имён во внутренней сети)
geosite:Встроенный списокgeosite:cnвсе домены из списка cn
ext:Список из файлаext:geosite.dat:cnэквивалент geosite:cn

«Голый» домен без префикса — это keyword (подстрока), а не полное совпадение

Если написать в domain просто "baidu.com" без префикса, ядро трактует это как подстроку (keyword), а не как точное имя. Это подтверждается кодом (тип по умолчанию — Domain_Substr). Чтобы поймать домен и поддомены, используйте явный domain:baidu.com; для точного совпадения — full:.

Технически на десктопе домены матчатся через MPH (minimal perfect hash) — быструю структуру; на iOS/Android — линейным перебором. Регулярки чувствительны к регистру.

Условие по IP (ip, sourceIP, localIP)

Список диапазонов. Форматы: одиночный IP (127.0.0.1), CIDR (10.0.0.0/8, ::/0), встроенный список стран (geoip:cn), спецзначение geoip:private (все приватные адреса), файл (ext:geoip.dat:cn).

Инверсия ! — важная и хитрая деталь. !geoip:cn означает «всё, что НЕ входит в geoip:cn». Логика объединения по документации: несколько отрицаний соединяются через И (AND), а положительные условия и совокупность отрицательных — через ИЛИ (OR).

Пример: ip: ["!geoip:cn", "!geoip:us", "geoip:telegram"] матчит IP, которые «не Китай И не США», ИЛИ являются IP Telegram.

Нюанс реализации инверсии

По коду AND между отрицаниями держится строго внутри одной «корзины» — отдельно для CIDR и отдельно для geoip. То есть !geoip:cn + !geoip:us действительно объединяются через AND, но отрицаемый CIDR и отрицаемый !geoip:cn окажутся в разных корзинах и соединятся через OR. Для типовых конфигов (несколько !geoip:) поведение совпадает с документацией.

Условия по портам и сети

  • port — порт назначения. Форматы: 443, диапазон 1000-2000, смесь через запятую 53,443,1000-2000.
  • sourcePort — порт источника (тот же формат).
  • localPort — порт локального inbound (полезно, когда inbound слушает диапазон портов).
  • networktcp, udp или tcp,udp. Условие "network": "tcp,udp" без других полей ловит весь трафик (ядро на 4-м уровне знает только TCP и UDP) — удобно как catch-all в конце списка правил.

Прочие условия

  • sourceIP (псевдоним source) — IP источника (IP/CIDR/geoip).
  • localIP — IP, на который пришёл inbound (при listen: 0.0.0.0). Не работает для UDP.
  • user — email пользователя-источника; поддерживает regexp:.
  • inboundTag — тег входящего канала, через который пришло соединение.
  • protocolhttp / tls / quic / bittorrent. Требует включённого sniffing, иначе тип пуст. Матч по префиксу (правило http поймает и распознанный http1).
  • attrs — сопоставление HTTP-заголовков и псевдозаголовков h2 (:method, :path), например {":method": "GET"}. Тоже требует sniffing; все указанные ключи должны совпасть (AND), значения поддерживают регулярки.
  • ruleTag — метка правила. На маршрутизацию не влияет, но при срабатывании пишет в лог (уровень Info) строку Hit route rule: [<ruleTag>] — удобно для отладки, какое правило сработало.

Условие по процессу (process)

Сопоставление по процессу-источнику — работает только для локальных соединений (Windows/Linux). Три режима: имя процесса (curl — ядро само отбрасывает .exe), абсолютный путь (C:/Windows/System32/curl.exe, слеши всегда прямые), папка (путь с завершающим / — все процессы внутри).

Два синтаксических сахара очень полезны:

  • self/ — матчит сам процесс ядра Xray. Главное применение — защита от петель маршрутизации (routing loops): трафик, порождённый самим ядром, не заворачивается обратно в себя.
  • xray/ — матчит все процессы Xray из того же бинарника (подставляется абсолютный путь к текущему исполняемому файлу).

На Android для сопоставления по приложениям нужно, чтобы клиентская обёртка зарегистрировала искатель процессов через RegisterAndroidProcessFinder().

vlessRoute: маршрутизация по байтам UUID

Необычная возможность: VLESS-клиент может изменить 7-й и 8-й байты своего UUID (в записи xxxxxxxx-xxxx-0000-xxxx-... это выделенные нули) на любое значение и использовать их как данные маршрутизации vlessRoute. Это позволяет клиенту управлять частью серверной маршрутизации, не меняя никаких внешних полей конфига.

Значение кодируется как big-endian uint16 (проще: воспринимайте эти 4 hex-символа как шестнадцатеричное число → в десятичное: 0001→1, 000e→14, 38b2→14514), а в правиле vlessRoute пишется в том же синтаксисе, что и порты (одиночные значения и диапазоны).

Механика (по коду): при проверке пользователя эти два байта обнуляются (ProcessUUID в validator.go), поэтому аутентификация по UUID не ломается — а исходное значение байтов сервер запоминает и использует для матчинга правила.

webhook: уведомление при срабатывании правила

Опционально к правилу можно привязать webhook — при попадании в правило ядро шлёт POST-запрос с JSON о соединении (email, protocol, network, source, destination, originalTarget, routeTarget, inboundTag, outboundTag, ts и др.). Полезно для алертов и статистики (например, «зафиксирован bittorrent»).

Поля объекта: url (обычный HTTP(S)-адрес или путь Unix-сокета, в т.ч. abstract-сокет @abstract / @@padded на Linux), deduplication (окно в секундах — повторные события по тому же пользователю в этот период не шлются), headers (доп. заголовки запроса, например X-API-Key).

Балансировщики нагрузки

Если правило указывает balancerTag вместо outboundTag, Xray выбирает конкретный outbound через балансировщик. Балансировщик описывается в balancers:

{
  "tag": "balancer",
  "selector": ["out"],
  "fallbackTag": "outbound",
  "strategy": { "type": "roundRobin" }
}
  • selector — массив префиксов тегов. Балансировщик работает с теми outbound, чьи теги начинаются с указанных префиксов: ["a"] захватит a, ab, abc. Это префиксное совпадение (HasPrefix), не точное.
  • fallbackTag — куда уйти, если по данным наблюдения все outbound недоступны.
  • strategy — стратегия выбора.

Если оба тега заданы — приоритет у outboundTag

В одном правиле можно указать outboundTag и balancerTag, но действует только один. По документации приоритет у outboundTag.

Четыре стратегии

СтратегияКак выбираетНужен наблюдатель
random (по умолчанию)Случайный из кандидатовОпционально
roundRobinПо очереди, циклическиОпционально
leastPingС наименьшей задержкойДа
leastLoadСамый стабильный (по разбросу RTT)Да (burst)
  • random / roundRobin могут работать и без наблюдателя. Если наблюдатель есть (подключается при заданном fallbackTag), недоступные узлы исключаются, а узлы без данных наблюдения считаются доступными.
  • leastPing / leastLoad требуют наблюдателя обязательно; узлы, не покрытые наблюдением, исключаются. Если все недоступны и fallbackTag не задан — берётся outbound по умолчанию.

Данные о доступности и задержке узлов поставляет observatory (простой health-check: HTTP-запрос через каждый outbound, даёт Alive + задержку) или burstObservatory (богаче: скользящее окно замеров со статистикой — среднее, макс/мин, доля отказов, стандартное отклонение RTT). Для leastLoad нужен именно burst, потому что он опирается на разброс RTT.

Настройки leastLoad

Стратегия leastLoad тонко настраивается через settings:

  • expected — сколько лучших узлов оставить (трафик случайно распределяется между ними).
  • maxRTT — отбрасывать узлы с задержкой выше порога.
  • tolerance — допустимая доля неудачных замеров (например 0.01 = 1%).
  • baselines — пороги стандартного отклонения RTT для отбора «стабильных» узлов.
  • costs — веса для outbound (regexp/match/value); чем больше value, тем менее вероятен выбор узла.

По коду leastLoad сортирует узлы по «стоимости отклонения RTT» (стабильность важнее среднего пинга), отбирает лучшие expected, а внутри них выбирает случайно — так нагрузка равномерно ложится на несколько хороших узлов.

Встроенные списки geosite и geoip

Xray поставляется с двумя файлами-справочниками:

  • geosite.dat — именованные списки доменов, используются как geosite:имя. Полезные: category-ads-all (реклама + рекламные сети), cn (сайты материкового Китая), google, microsoft, apple, telegram, geolocation-cn / geolocation-!cn (китайские / некитайские), tld-cn / tld-!cn (домены по TLD). Полный список — в community-репозитории доменов.
  • geoip.dat — списки IP-диапазонов по странам, используются как geoip:код_страны (почти все страны). Плюс спец-значение geoip:private (приватные подсети).

Именно на этих списках строятся типовые правила «китайские/российские сайты — напрямую, остальное — через прокси». Синтаксис ext:файл:тег позволяет подключать свои .dat-файлы из директории ресурсов.

Практический смысл для обхода блокировок

Всё это — фундамент раздельной маршрутизации: правило с geoip:ru/geosite: и outboundTag: direct пускает российские сайты напрямую, а «ловушка» в конце гонит остальное через прокси-outbound. На стороне пользователя те же правила приезжают готовым набором (например через HAPP и routing.help) — как это выглядит на практике, разобрано в заметке про клиенты и маршрутизацию.

📚 См. также


🤖 Эти статьи открыты — можно обучать на них ИИ

При желании вы можете натренировать ИИ на наших статьях. Исходное форматирование и скачивание всего репозитория одним zip-архивом доступны в Forgejo: исходник этой заметки · весь репозиторий.