Обзор архитектуры DNS
Подсистема DNS Xray представляет собой полностью автономный DNS-резолвер, заменяющий системный резолвер ОС. Она поддерживает маршрутизацию DNS-запросов на основе доменов к различным upstream-серверам, статические переопределения хостов, параллельные/последовательные стратегии запросов, фильтрацию IP и интеграцию с Fake DNS.
Основные структуры данных
Структура DNS
Центральный координатор находится в app/dns/dns.go. Он реализует интерфейс фичи dns.Client и управляет всей логикой запросов.
// app/dns/dns.go
type DNS struct {
sync.Mutex
disableFallback bool
disableFallbackIfMatch bool
enableParallelQuery bool
ipOption *dns.IPOption
hosts *StaticHosts
clients []*Client
ctx context.Context
domainMatcher strmatcher.IndexMatcher
matcherInfos []*DomainMatcherInfo
checkSystem bool
}Ключевые поля:
| Поле | Назначение |
|---|---|
clients | Упорядоченный список обёрток *Client, каждая из которых содержит реализацию Server |
hosts | Статические соответствия домен-IP (секция конфигурации "hosts") |
domainMatcher | strmatcher.MatcherGroup, индексирующий доменные правила всех клиентов |
matcherInfos | Отображение индекса сопоставления обратно в (clientIdx, domainRuleIdx) |
ipOption | Глобальная стратегия запросов (IPv4/IPv6/оба) |
enableParallelQuery | Переключение между последовательным и параллельным режимами запросов |
disableFallback / disableFallbackIfMatch | Управление использованием несопоставленных клиентов в качестве fallback |
Интерфейс Server
Определён в app/dns/nameserver.go, представляет контракт для всех реализаций DNS upstream:
type Server interface {
Name() string
IsDisableCache() bool
QueryIP(ctx context.Context, domain string, option dns.IPOption) ([]net.IP, uint32, error)
}Каждый тип сервера (UDP, TCP, DoH, DoQ, Local, FakeDNS) реализует этот интерфейс.
Структура Client
Client оборачивает Server с политикой для каждого клиента: доменные правила, фильтрация ожидаемых/неожидаемых IP, тайм-аут, тег для маршрутизации и переопределение стратегии запросов.
// app/dns/nameserver.go
type Client struct {
server Server
skipFallback bool
domains []string
expectedIPs router.GeoIPMatcher
unexpectedIPs router.GeoIPMatcher
actPrior bool
actUnprior bool
tag string
timeoutMs time.Duration
finalQuery bool
ipOption *dns.IPOption
checkSystem bool
policyID uint32
}Диаграмма архитектуры
flowchart TD
A[Вызван LookupIP] --> B{Совпадение со статическими хостами?}
B -->|Замена домена| C[Замена домена, продолжение]
B -->|Найден IP| D[Возврат IP, TTL=10]
B -->|Не найден| E{Параллельный запрос включён?}
E -->|Да| F[parallelQuery]
E -->|Нет| G[serialQuery]
G --> H[sortClients по доменным правилам]
H --> I[Запрос клиентов по порядку]
I --> J{IP получены?}
J -->|Да| K[Применение фильтра expectedIPs / unexpectedIPs]
J -->|Нет| L[Попробовать следующего клиента или вернуть ошибку]
K --> M[Возврат отфильтрованных IP]
F --> N[sortClients по доменным правилам]
N --> O[asyncQueryAll — отправка всех запросов]
O --> P[Сбор результатов по группам]
P --> Q{Какая-либо группа успешна?}
Q -->|Да| M
Q -->|Нет| LПоследовательность инициализации
Функция New() в app/dns/dns.go создаёт движок DNS:
Разбор стратегии запросов — Отображение
QueryStrategy_USE_IP,USE_IP4,USE_IP6,USE_SYSво флагиdns.IPOption. СтратегияUSE_SYSвключает проверку маршрутов во время выполнения черезcheckRoutes().Построение статических хостов — Загрузка из файла кеша MPH (минимальное совершенное хеширование) или построение из
config.StaticHosts.Построение клиентов — Для каждого
NameServerв конфигурации вызываетсяNewClient(), который:- Создаёт
ServerчерезNewServer()(диспетчеризация по схеме URL) - Регистрирует доменные правила в общем
domainMatcher - Строит сопоставители GeoIP для
expectedIPsиunexpectedIPs - Устанавливает кеш для каждого сервера, обслуживание устаревших записей, тайм-аут и тег
- Создаёт
Клиент по умолчанию — Если серверы не настроены, автоматически добавляется
LocalNameServer(системный резолвер).
Маршрутизация на основе доменов
Система DNS маршрутизирует запросы к различным upstream-серверам на основе правил соответствия доменов. Это метод sortClients():
func (s *DNS) sortClients(domain string) []*Client {
// 1. Match domain against domainMatcher (returns indices)
// 2. Sort matched indices, look up (clientIdx, domainRuleIdx) pairs
// 3. Add matched clients in priority order (deduplicating)
// 4. If finalQuery is set on a matched client, stop immediately
// 5. Unless fallback is disabled, append remaining unmatched clients
// 6. If no clients matched and fallback is disabled, use first client
}Каждый DomainMatcherInfo хранит информацию о том, какой клиент и какое конкретное доменное правило совпали:
type DomainMatcherInfo struct {
clientIdx uint16
domainRuleIdx uint16
}Это означает, что домен "google.com" может совпасть с правилом "geosite:google" сервера A, и DNS-запрос сначала отправится на сервер A, а затем будет использован fallback на другие серверы.
Режимы запросов
Последовательный запрос
serialQuery() итерирует по отсортированному списку клиентов последовательно. Первый клиент, вернувший непустой набор IP, выигрывает. Если клиент возвращает ошибку, пробуется следующий.
func (s *DNS) serialQuery(domain string, option dns.IPOption) ([]net.IP, uint32, error) {
for _, client := range s.sortClients(domain) {
ips, ttl, err := client.QueryIP(s.ctx, domain, option)
if len(ips) > 0 {
return ips, ttl, nil
}
// log error, continue to next client
}
return nil, 0, mergeQueryErrors(domain, errs)
}Параллельный запрос
parallelQuery() одновременно отправляет запросы ко всем отсортированным клиентам через asyncQueryAll(), а затем собирает результаты, используя стратегию гонки по группам:
Группировка: Соседние клиенты с одинаковым
policyIDобразуют группу. ФункцияmakeGroups()объединяет только соседних клиентов с эквивалентными правилами.Гонка групп: Внутри каждой группы побеждает первый успешный результат (минимальный RTT). Если все члены группы терпят неудачу, пробуется следующая группа.
Обработка тайм-аутов: Для серверов с включённым кешированием тайм-аут контекста удваивается (
c.timeoutMs * 2) с использованиемcontext.WithoutCancel, чтобы разрешить фоновое обновление кеша.
func asyncQueryAll(domain string, option dns.IPOption, clients []*Client, ctx context.Context) chan queryResult {
ch := make(chan queryResult, len(clients))
for i, client := range clients {
go func(i int, c *Client) {
ips, ttl, err := c.QueryIP(qctx, domain, option)
ch <- queryResult{ips: ips, ttl: ttl, err: err, index: i}
}(i, client)
}
return ch
}Фильтрация IP на клиенте
После того как сервер возвращает IP-адреса, метод Client.QueryIP() применяет два фильтра:
- expectedIPs — Если
actPriorимеет значение false, сохраняются только IP, соответствующие набору GeoIP (строгий фильтр). ЕслиactPriorимеет значение true, соответствующие IP приоритизируются, но несовпадающие IP сохраняются как fallback. - unexpectedIPs — Обратный фильтр: IP, соответствующие «неожиданному» набору, удаляются (или депреоритизируются, если
actUnpriorимеет значение true).
Это позволяет реализовать «фильтрацию отравленных DNS», когда отечественный DNS-сервер может возвращать отравленные IP для зарубежных доменов.
Проверка маршрутов (USE_SYS)
Когда QueryStrategy установлена в USE_SYS, система DNS проверяет фактическое сетевое подключение перед выполнением запроса:
func probeRoutes() (ipv4 bool, ipv6 bool) {
// Dial root DNS servers to check IPv4/IPv6 connectivity
if conn, err := net.Dial("udp4", "192.33.4.12:53"); err == nil { ipv4 = true }
if conn, err := net.Dial("udp6", "[2001:500:2::c]:53"); err == nil { ipv6 = true }
}На GUI-платформах (Android, iOS, Windows, macOS) результат проверки кешируется на 100 мс и перепроверяется для обработки сетевых изменений. На серверных платформах проверка выполняется только один раз.
Ключевые файлы исходного кода
| Файл | Назначение |
|---|---|
app/dns/dns.go | Структура DNS, New(), LookupIP(), sortClients(), последовательный/параллельный запрос |
app/dns/nameserver.go | Интерфейс Server, структура Client, фабрика NewServer(), NewClient(), фильтрация IP |
app/dns/dnscommon.go | IPRecord, dnsRequest, buildReqMsgs(), parseResponse(), опции EDNS0 |
app/dns/hosts.go | StaticHosts для конфигурационного отображения "hosts" |
app/dns/config.go | Помощники сопоставления доменов (toStrMatcher), правила локальных TLD |
Замечания по реализации
Структура
DNSрегистрирует себя черезcommon.RegisterConfig((*Config)(nil), ...)вinit(), поэтому ядро создаёт её, когда protobuf конфигурации DNS появляется в списке приложений.Поле
tagкаждого клиента критически важно: оно становится тегом inbound для контекста маршрутизации DNS-запроса (session.ContextWithInbound). Это позволяет правилам маршрутизации направлять DNS-трафик через определённые исходящие соединения (например, отправка DNS-over-HTTPS через прокси).finalQueryна сервере имён заставляетsortClients()прекратить добавление клиентов после этого сервера. Это предотвращает fallback для определённых доменных правил.policyIDвычисляется во время построения конфигурации на основе хеша свойств конфигурации сервера (домены, ожидаемые IP, стратегия запросов, тег и т.д.). Серверы с одинаковыми идентификаторами политик группируются для параллельной гонки.Когда все клиенты терпят неудачу,
mergeQueryErrors()консолидирует ошибки. Если все ошибки —errRecordNotFound(сервер не дал ответа), возвращается общая ошибкаdns.ErrEmptyResponse.Управление памятью явное: после обработки доменных правил и списков GeoIP во время инициализации вызывается
runtime.GC()для освобождения временных protobuf-структур.