Skip to content

DNS 架构概述

Xray DNS 子系统是一个完全自包含的 DNS 解析器,可替代操作系统解析器。它支持基于域名的 DNS 查询路由(分发到多个上游服务器)、静态主机覆盖、并行/串行查询策略、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控制是否使用未匹配的客户端作为回退

Server 接口

定义于 app/dns/nameserver.go,这是所有 DNS 上游实现的契约:

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 过滤、超时、用于路由的 tag,以及查询策略覆盖。

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{静态 hosts 匹配?}
    B -->|域名替换| C[替换域名,继续处理]
    B -->|找到 IP| D[返回 IP,TTL=10]
    B -->|未找到| E{并行查询已启用?}

    E -->|是| F[parallelQuery]
    E -->|否| G[serialQuery]

    G --> H[按域名规则排序客户端]
    H --> I[按顺序查询客户端]
    I --> J{返回了 IP?}
    J -->|是| K[应用 expectedIPs / unexpectedIPs 过滤]
    J -->|否| L[尝试下一个客户端或返回错误]
    K --> M[返回过滤后的 IP]

    F --> N[按域名规则排序客户端]
    N --> O[asyncQueryAll - 同时发起所有查询]
    O --> P[按分组收集结果]
    P --> Q{有分组成功?}
    Q -->|是| M
    Q -->|否| L

初始化流程

app/dns/dns.go 中的 New() 函数构建 DNS 引擎:

  1. 解析查询策略 -- 将 QueryStrategy_USE_IPUSE_IP4USE_IP6USE_SYS 映射到 dns.IPOption 标志。USE_SYS 策略通过 checkRoutes() 启用运行时路由探测。

  2. 构建静态 hosts -- 从 MPH(最小完美哈希)缓存文件加载,或从 config.StaticHosts 构造。

  3. 构建客户端 -- 对于配置中的每个 NameServer,调用 NewClient(),该方法会:

    • 通过 NewServer() 创建 Server(根据 URL scheme 分发)
    • 将域名规则注册到共享的 domainMatcher
    • 构建 expectedIPsunexpectedIPs GeoIP 匹配器
    • 设置逐服务器的缓存、过期服务、超时和 tag
  4. 默认客户端 -- 如果未配置任何服务器,会自动添加一个 LocalNameServer(系统解析器)。

基于域名的路由

DNS 系统根据域名匹配规则将查询路由到不同的上游服务器。这是 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" 可能匹配服务器 A 的规则 "geosite:google",于是 DNS 查询首先发往服务器 A,然后回退到其他服务器。

查询模式

串行查询

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,仅保留匹配 GeoIP 集合的 IP(严格过滤)。如果 actPrior 为 true,匹配的 IP 优先但不匹配的 IP 作为回退保留。
  • unexpectedIPs -- 相反:匹配"非期望"集合的 IP 被移除(或在 actUnprior 为 true 时降低优先级)。

这实现了"DNS 污染过滤"——国内 DNS 服务器可能对境外域名返回被污染的 IP。

路由探测(USE_SYS)

QueryStrategyUSE_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)上,此探测结果缓存 100ms 并重新检查以处理网络变化。在服务器平台上仅运行一次。

关键源文件

文件用途
app/dns/dns.goDNS 结构体、New()LookupIP()sortClients()、串行/并行查询
app/dns/nameserver.goServer 接口、Client 结构体、NewServer() 工厂、NewClient()、IP 过滤
app/dns/dnscommon.goIPRecorddnsRequestbuildReqMsgs()parseResponse()、EDNS0 选项
app/dns/hosts.goStaticHosts,用于 "hosts" 配置映射
app/dns/config.go域名匹配器辅助函数(toStrMatcher)、本地 TLD 规则

实现要点

  • DNS 结构体通过 init() 中的 common.RegisterConfig((*Config)(nil), ...) 注册自身,因此当 DNS 配置 protobuf 出现在应用列表中时,core 会创建它。

  • 每个 Client 上的 tag 字段至关重要:它成为 DNS 查询路由上下文中的入站 tag(session.ContextWithInbound)。这使路由规则可以将 DNS 流量定向到特定的出站(例如,将 DoH 流量通过代理发送)。

  • 命名服务器上的 finalQuery 会使 sortClients() 在该服务器之后停止添加客户端。这可以阻止特定域名规则的回退。

  • policyID 在配置构建时基于服务器配置属性(域名、期望 IP、查询策略、tag 等)的哈希值计算。具有相同 policy ID 的服务器在并行竞争中被分为同一组。

  • 当所有客户端都失败时,mergeQueryErrors() 会合并错误。如果所有错误都是 errRecordNotFound(服务器无响应),则返回通用的 dns.ErrEmptyResponse

  • 内存管理是显式的:在初始化期间处理完域名规则和 GeoIP 列表后,调用 runtime.GC() 释放临时的 protobuf 结构。

用于重新实现目的的技术分析。