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-структур.

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