Skip to content

Обзор архитектуры DNS ​

Подсистема DNS Xray представляет собой полностью автономный DNS-резолвер, заменяющий системный резолвер ОС. Она поддерживает маршрутизацию DNS-запросов на основе доменов к различным upstream-серверам, статические переопределения хостов, параллельные/последовательные стратегии запросов, фильтрацию IP и интеграцию с Fake DNS.

Основные структуры данных ​

Структура DNS ​

Центральный координатор находится в app/dns/dns.go. Он реализует интерфейс фичи dns.Client и управляет всей логикой запросов.

go
// 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")
domainMatcherstrmatcher.MatcherGroup, индексирующий доменные правила всех клиентов
matcherInfosОтображение индекса сопоставления обратно в (clientIdx, domainRuleIdx)
ipOptionГлобальная стратегия запросов (IPv4/IPv6/оба)
enableParallelQueryПереключение между последовательным и параллельным режимами запросов
disableFallback / disableFallbackIfMatchУправление использованием несопоставленных клиентов в качестве fallback

Интерфейс Server ​

Определён в app/dns/nameserver.go, представляет контракт для всех реализаций DNS upstream:

go
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, тайм-аут, тег для маршрутизации и переопределение стратегии запросов.

go
// 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
}

Диаграмма архитектуры ​

mermaid
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:

  1. Разбор стратегии запросов — Отображение QueryStrategy_USE_IP, USE_IP4, USE_IP6, USE_SYS во флаги dns.IPOption. Стратегия USE_SYS включает проверку маршрутов во время выполнения через checkRoutes().

  2. Построение статических хостов — Загрузка из файла кеша MPH (минимальное совершенное хеширование) или построение из config.StaticHosts.

  3. Построение клиентов — Для каждого NameServer в конфигурации вызывается NewClient(), который:

    • Создаёт Server через NewServer() (диспетчеризация по схеме URL)
    • Регистрирует доменные правила в общем domainMatcher
    • Строит сопоставители GeoIP для expectedIPs и unexpectedIPs
    • Устанавливает кеш для каждого сервера, обслуживание устаревших записей, тайм-аут и тег
  4. Клиент по умолчанию — Если серверы не настроены, автоматически добавляется LocalNameServer (системный резолвер).

Маршрутизация на основе доменов ​

Система DNS маршрутизирует запросы к различным upstream-серверам на основе правил соответствия доменов. Это метод sortClients():

go
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 хранит информацию о том, какой клиент и какое конкретное доменное правило совпали:

go
type DomainMatcherInfo struct {
    clientIdx     uint16
    domainRuleIdx uint16
}

Это означает, что домен "google.com" может совпасть с правилом "geosite:google" сервера A, и DNS-запрос сначала отправится на сервер A, а затем будет использован fallback на другие серверы.

Режимы запросов ​

Последовательный запрос ​

serialQuery() итерирует по отсортированному списку клиентов последовательно. Первый клиент, вернувший непустой набор IP, выигрывает. Если клиент возвращает ошибку, пробуется следующий.

go
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(), а затем собирает результаты, используя стратегию гонки по группам:

  1. Группировка: Соседние клиенты с одинаковым policyID образуют группу. Функция makeGroups() объединяет только соседних клиентов с эквивалентными правилами.

  2. Гонка групп: Внутри каждой группы побеждает первый успешный результат (минимальный RTT). Если все члены группы терпят неудачу, пробуется следующая группа.

  3. Обработка тайм-аутов: Для серверов с включённым кешированием тайм-аут контекста удваивается (c.timeoutMs * 2) с использованием context.WithoutCancel, чтобы разрешить фоновое обновление кеша.

go
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 проверяет фактическое сетевое подключение перед выполнением запроса:

go
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.goIPRecord, dnsRequest, buildReqMsgs(), parseResponse(), опции EDNS0
app/dns/hosts.goStaticHosts для конфигурационного отображения "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-структур.

Технический анализ для целей повторной реализации.