Реализации DNS-серверов
Xray реализует пять различных типов DNS-серверов плюс псевдосервер FakeDNS. Все они реализуют интерфейс Server из app/dns/nameserver.go. Тип сервера выбирается фабрикой NewServer() на основе схемы URL адреса сервера имён.
Логика диспетчеризации серверов
Функция NewServer() в app/dns/nameserver.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-пакетам проходить через цепочки прокси.
Сводка типов серверов
| Тип | Структура | Схема | Транспорт | Через диспетчер |
|---|---|---|---|---|
| UDP | ClassicNameServer | IP-адрес / без схемы | UDP | Да |
| TCP | TCPNameServer | tcp:// | TCP | Да (удалённый) / Нет (локальный) |
| DoH | DoHNameServer | https:// или h2c:// | HTTP/2 POST | Да (удалённый) / Нет (локальный) |
| DoQ | QUICNameServer | quic+local:// | QUIC-потоки | Нет (только локальный) |
| Локальный | LocalNameServer | localhost | Системный резолвер | Нет |
| FakeDNS | FakeDNSServer | fakedns | Нет (в памяти) | Нет |
Паттерн CachedNameserver
Все реальные DNS-серверы (UDP, TCP, DoH, DoQ) используют общий паттерн запросов через интерфейс CachedNameserver, определённый в app/dns/nameserver_cached.go:
type CachedNameserver interface {
getCacheController() *CacheController
sendQuery(ctx context.Context, noResponseErrCh chan<- error, fqdn string, option dns.IPOption)
}Функция queryIP() управляет потоком «проверка кеша — затем запрос»:
- Проверка кеша на попадание (см. caching.md)
- Если запись устарела и
serveStaleвключён, возврат устаревшего результата с фоновым обновлением - В противном случае вызов
fetch(), использующийsingleflight.Groupдля дедупликации параллельных запросов fetch()регистрирует подписчиков pubsub, вызываетsendQuery()и ожидает ответов
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
endUDP-сервер (ClassicNameServer)
Файл: app/dns/nameserver_udp.go
Классическая реализация DNS-over-UDP. Управляет картой ожидающих запросов, индексированных по ID DNS-сообщения.
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
}Поток запроса:
sendQuery()создаёт сообщения запросов A и/или AAAA с помощьюbuildReqMsgs()- Каждое сообщение упаковывается через
dns.PackMessage()и отправляется черезudp.Dispatcher - Ответы приходят асинхронно через колбэк
HandleResponse() - При усечении запрос повторяется с ресурсом EDNS0 OPT (размер UDP-полезной нагрузки 1350)
- Успешные ответы передаются в
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. Существуют два варианта: удалённый (диспетчеризуемый) и локальный (прямой).
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():
- Упаковывает DNS-сообщение
- Записывает
uint16(length) || messageв соединение - Читает
uint16(length), затем тело ответа - Разбирает и обновляет кеш
Каждый запрос открывает новое TCP-соединение (без пула соединений).
DoH-сервер (DoHNameServer)
Файл: app/dns/nameserver_doh.go
DNS-over-HTTPS с использованием HTTP/2, совместим с форматом передачи RFC 8484.
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.
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
Тонкая обёртка над системным резолвером ОС.
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-адреса из настроенного пула.
type FakeDNSServer struct {
fakeDNSEngine dns.FakeDNSEngine
}- Возвращает поддельные IP с TTL=1 (для предотвращения кеширования downstream)
- Поддерживает двойной стек через
FakeDNSEngineRev0.GetFakeIPForDomain3() - Фича
dns.FakeDNSEngineразрешается черезcore.RequireFeatures() - Кеш всегда отключён
- Пропускается при запросе, если
option.FakeEnableравно false
Маршрутизация DNS-трафика
DNS-запросы маршрутизируются через диспетчер Xray с использованием поля tag клиента. Функция toDnsContext() создаёт контекст маршрутизации:
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.