Skip to content

Реализации DNS-серверов

Xray реализует пять различных типов DNS-серверов плюс псевдосервер FakeDNS. Все они реализуют интерфейс Server из app/dns/nameserver.go. Тип сервера выбирается фабрикой NewServer() на основе схемы URL адреса сервера имён.

Логика диспетчеризации серверов

Функция NewServer() в app/dns/nameserver.go направляет создание сервера:

go
func NewServer(ctx context.Context, dest net.Destination, dispatcher routing.Dispatcher, ...) (Server, error) {
    if address := dest.Address; address.Family().IsDomain() {
        u, _ := url.Parse(address.Domain())
        switch {
        case strings.EqualFold(u.String(), "localhost"):       // -> LocalNameServer
        case strings.EqualFold(u.Scheme, "https"):             // -> DoHNameServer (remote)
        case strings.EqualFold(u.Scheme, "h2c"):               // -> DoHNameServer (h2c remote)
        case strings.EqualFold(u.Scheme, "https+local"):       // -> DoHNameServer (local)
        case strings.EqualFold(u.Scheme, "h2c+local"):         // -> DoHNameServer (h2c local)
        case strings.EqualFold(u.Scheme, "quic+local"):        // -> QUICNameServer
        case strings.EqualFold(u.Scheme, "tcp"):               // -> TCPNameServer (remote)
        case strings.EqualFold(u.Scheme, "tcp+local"):         // -> TCPNameServer (local)
        case strings.EqualFold(u.String(), "fakedns"):          // -> FakeDNSServer
        }
    }
    if dest.Network == net.Network_UDP {                        // -> ClassicNameServer
        return NewClassicNameServer(dest, dispatcher, ...)
    }
}

Суффикс +local означает, что сервер подключается напрямую без прохождения через маршрутизацию/диспетчер Xray. Удалённые варианты направляют DNS-трафик через диспетчер, позволяя DNS-пакетам проходить через цепочки прокси.

Сводка типов серверов

ТипСтруктураСхемаТранспортЧерез диспетчер
UDPClassicNameServerIP-адрес / без схемыUDPДа
TCPTCPNameServertcp://TCPДа (удалённый) / Нет (локальный)
DoHDoHNameServerhttps:// или h2c://HTTP/2 POSTДа (удалённый) / Нет (локальный)
DoQQUICNameServerquic+local://QUIC-потокиНет (только локальный)
ЛокальныйLocalNameServerlocalhostСистемный резолверНет
FakeDNSFakeDNSServerfakednsНет (в памяти)Нет

Паттерн CachedNameserver

Все реальные DNS-серверы (UDP, TCP, DoH, DoQ) используют общий паттерн запросов через интерфейс CachedNameserver, определённый в app/dns/nameserver_cached.go:

go
type CachedNameserver interface {
    getCacheController() *CacheController
    sendQuery(ctx context.Context, noResponseErrCh chan<- error, fqdn string, option dns.IPOption)
}

Функция queryIP() управляет потоком «проверка кеша — затем запрос»:

  1. Проверка кеша на попадание (см. caching.md)
  2. Если запись устарела и serveStale включён, возврат устаревшего результата с фоновым обновлением
  3. В противном случае вызов fetch(), использующий singleflight.Group для дедупликации параллельных запросов
  4. fetch() регистрирует подписчиков pubsub, вызывает sendQuery() и ожидает ответов
mermaid
sequenceDiagram
    participant Caller as Вызывающий
    participant queryIP
    participant Cache as CacheController
    participant PubSub
    participant sendQuery
    participant Upstream

    Caller->>queryIP: QueryIP(domain, option)
    queryIP->>Cache: findRecords(fqdn)
    alt Попадание в кеш (TTL > 0)
        Cache-->>queryIP: кешированные IP
        queryIP-->>Caller: возврат IP
    else Устаревший кеш + serveStale
        Cache-->>queryIP: устаревшие IP
        queryIP->>sendQuery: фоновое обновление
        queryIP-->>Caller: возврат устаревших IP (TTL=1)
    else Промах кеша
        queryIP->>PubSub: registerSubscribers(domain)
        queryIP->>sendQuery: sendQuery(domain)
        sendQuery->>Upstream: DNS wire protocol
        Upstream-->>sendQuery: ответ
        sendQuery->>Cache: updateRecord()
        Cache->>PubSub: Publish(domain+"4"|"6")
        PubSub-->>queryIP: IPRecord
        queryIP-->>Caller: возврат IP
    end

UDP-сервер (ClassicNameServer)

Файл: app/dns/nameserver_udp.go

Классическая реализация DNS-over-UDP. Управляет картой ожидающих запросов, индексированных по ID DNS-сообщения.

go
type ClassicNameServer struct {
    sync.RWMutex
    cacheController *CacheController
    address         *net.Destination
    requests        map[uint16]*udpDnsRequest
    udpServer       *udp.Dispatcher
    requestsCleanup *task.Periodic
    reqID           uint32
    clientIP        net.IP
}

Поток запроса:

  1. sendQuery() создаёт сообщения запросов A и/или AAAA с помощью buildReqMsgs()
  2. Каждое сообщение упаковывается через dns.PackMessage() и отправляется через udp.Dispatcher
  3. Ответы приходят асинхронно через колбэк HandleResponse()
  4. При усечении запрос повторяется с ресурсом EDNS0 OPT (размер UDP-полезной нагрузки 1350)
  5. Успешные ответы передаются в cacheController.updateRecord()

Очистка запросов: task.Periodic запускается каждую минуту для удаления запросов старше 8 секунд.

EDNS0 Client Subnet: Если настроен clientIP, добавляется опция EDNS0 Subnet с /24 для IPv4 или /96 для IPv6.

TCP-сервер (TCPNameServer)

Файл: app/dns/nameserver_tcp.go

DNS-over-TCP по RFC 7766. Существуют два варианта: удалённый (диспетчеризуемый) и локальный (прямой).

go
type TCPNameServer struct {
    cacheController *CacheController
    destination     *net.Destination
    reqID           uint32
    dial            func(context.Context) (net.Conn, error)
    clientIP        net.IP
}

Удалённый vs локальный: Замыкание функции dial различается:

  • Удалённый (tcp://): Использует dispatcher.Dispatch() для создания маршрутизируемого соединения, преобразуемого в net.Conn через cnc.NewConnection()
  • Локальный (tcp+local://): Использует internet.DialSystem() для прямого системного соединения

Формат передачи: TCP DNS предваряет каждое сообщение 2-байтовым префиксом длины в формате big-endian. Метод sendQuery():

  1. Упаковывает DNS-сообщение
  2. Записывает uint16(length) || message в соединение
  3. Читает uint16(length), затем тело ответа
  4. Разбирает и обновляет кеш

Каждый запрос открывает новое TCP-соединение (без пула соединений).

DoH-сервер (DoHNameServer)

Файл: app/dns/nameserver_doh.go

DNS-over-HTTPS с использованием HTTP/2, совместим с форматом передачи RFC 8484.

go
type DoHNameServer struct {
    cacheController *CacheController
    httpClient      *http.Client
    dohURL          string
    clientIP        net.IP
}

Ключевые проектные решения:

  • Использует http2.Transport напрямую (не стандартный http.Transport) для полного контроля HTTP/2
  • TLS-рукопожатие использует utls.UClient с HelloChrome_Auto для имитации TLS-отпечатка Chrome
  • Вариант h2c (h2c://) пропускает TLS полностью для незашифрованного HTTP/2
  • Применяется EDNS0-паддинг случайной длины (100-300 байт) для маскировки размеров запросов
  • HTTP-запрос включает заголовок X-Padding со случайным паддингом base62
  • Обнаружение саморазрешения: если DoH-сервер пытается разрешить собственное имя хоста, возникает ошибка

Формат запроса: POST на URL DoH с Content-Type: application/dns-message и Accept: application/dns-message. Тело — необработанный DNS wire format.

Режимы подключения:

  • Удалённый (dispatcher != nil): DNS-трафик маршрутизируется через исходящую систему Xray
  • Локальный (dispatcher == nil): Прямое системное подключение с журналированием доступа

DoQ-сервер (QUICNameServer)

Файл: app/dns/nameserver_quic.go

DNS-over-QUIC, только локальный режим. Использует библиотеку quic-go.

go
type QUICNameServer struct {
    sync.RWMutex
    cacheController *CacheController
    destination     *net.Destination
    connection      *quic.Conn
    clientIP        net.IP
}

Управление соединением:

  • Поддерживает единственное постоянное QUIC-соединение с ленивым переподключением
  • Использует токены ALPN: "doq", "http/1.1", "h2"
  • Порт по умолчанию — 853
  • Тайм-аут рукопожатия: 8 секунд
  • При сбое подключения одна попытка повтора перед возвратом ошибки

Потоки для каждого запроса: Каждый DNS-запрос открывает новый QUIC-поток через conn.OpenStreamSync(). Формат сообщения использует 2-байтовый префикс длины, как в TCP DNS.

Локальный сервер (LocalNameServer)

Файл: app/dns/nameserver_local.go

Тонкая обёртка над системным резолвером ОС.

go
type LocalNameServer struct {
    client *localdns.Client
}
  • Кеш всегда отключён (IsDisableCache() возвращает true)
  • Автоматически добавляет доменные правила geosite:private (локальные TLD и бесточечные домены)
  • При добавлении как единственного сервера (по умолчанию) оборачивается в Client с глобальным ipOption
  • Использует Go net.Resolver под капотом через localdns.Client

Сервер FakeDNS (FakeDNSServer)

Файл: app/dns/nameserver_fakedns.go

Не настоящий DNS-сервер — генерирует поддельные IP-адреса из настроенного пула.

go
type FakeDNSServer struct {
    fakeDNSEngine dns.FakeDNSEngine
}
  • Возвращает поддельные IP с TTL=1 (для предотвращения кеширования downstream)
  • Поддерживает двойной стек через FakeDNSEngineRev0.GetFakeIPForDomain3()
  • Фича dns.FakeDNSEngine разрешается через core.RequireFeatures()
  • Кеш всегда отключён
  • Пропускается при запросе, если option.FakeEnable равно false

Маршрутизация DNS-трафика

DNS-запросы маршрутизируются через диспетчер Xray с использованием поля tag клиента. Функция toDnsContext() создаёт контекст маршрутизации:

go
func toDnsContext(ctx context.Context, addr string) context.Context {
    dnsCtx := core.ToBackgroundDetachedContext(ctx)
    // Preserve inbound tag for routing
    dnsCtx = session.ContextWithInbound(dnsCtx, inbound)
    dnsCtx = session.ContextWithContent(dnsCtx, ...)
    dnsCtx = log.ContextWithAccessMessage(dnsCtx, ...)
    return dnsCtx
}

Содержимое сессии устанавливает Protocol: "dns" (или "https" для DoH, "quic" для DoQ) и SkipDNSResolve: true для предотвращения рекурсивного разрешения DNS.

Замечания по реализации

  • Все реализации sendQuery() запускают запросы A и AAAA параллельно в горутинах, когда включены и IPv4, и IPv6.

  • Канал noResponseErrCh (ёмкость 2) позволяет sendQuery сообщать ошибки транспортного уровня обратно вызывающему doFetch(), предотвращая бесконечное ожидание подписчика pubsub.

  • Генерация ID запросов различается: UDP использует атомарный счётчик для уникальных ID (необходимо для мультиплексированных ответов), тогда как DoH и DoQ всегда используют ID 0 (поскольку каждый запрос получает собственный HTTP-запрос или QUIC-поток).

  • Серверы TCP и DoH используют context.WithDeadline из родительского контекста, тогда как UDP использует таймер периодической очистки (8 секунд) для контроля тайм-аута.

  • Метод IsOwnLink() структуры DNS проверяет, совпадает ли текущий тег inbound с тегом какого-либо DNS-клиента, предотвращая петли разрешения DNS.

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