DNS 架构概述
Xray DNS 子系统是一个完全自包含的 DNS 解析器,可替代操作系统解析器。它支持基于域名的 DNS 查询路由(分发到多个上游服务器)、静态主机覆盖、并行/串行查询策略、IP 过滤,以及与 Fake DNS 的集成。
核心数据结构
DNS 结构体
核心协调器位于 app/dns/dns.go。它实现了 dns.Client 功能接口,并协调所有查询逻辑。
// 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" 部分) |
domainMatcher | strmatcher.MatcherGroup,索引所有客户端的域名规则 |
matcherInfos | 将匹配器索引映射回 (clientIdx, domainRuleIdx) |
ipOption | 全局查询策略(IPv4/IPv6/双栈) |
enableParallelQuery | 在串行和并行查询模式之间切换 |
disableFallback / disableFallbackIfMatch | 控制是否使用未匹配的客户端作为回退 |
Server 接口
定义于 app/dns/nameserver.go,这是所有 DNS 上游实现的契约:
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,以及查询策略覆盖。
// 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
}架构图
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 引擎:
解析查询策略 -- 将
QueryStrategy_USE_IP、USE_IP4、USE_IP6、USE_SYS映射到dns.IPOption标志。USE_SYS策略通过checkRoutes()启用运行时路由探测。构建静态 hosts -- 从 MPH(最小完美哈希)缓存文件加载,或从
config.StaticHosts构造。构建客户端 -- 对于配置中的每个
NameServer,调用NewClient(),该方法会:- 通过
NewServer()创建Server(根据 URL scheme 分发) - 将域名规则注册到共享的
domainMatcher - 构建
expectedIPs和unexpectedIPsGeoIP 匹配器 - 设置逐服务器的缓存、过期服务、超时和 tag
- 通过
默认客户端 -- 如果未配置任何服务器,会自动添加一个
LocalNameServer(系统解析器)。
基于域名的路由
DNS 系统根据域名匹配规则将查询路由到不同的上游服务器。这是 sortClients() 方法:
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 存储了匹配的客户端和对应的域名规则:
type DomainMatcherInfo struct {
clientIdx uint16
domainRuleIdx uint16
}这意味着域名 "google.com" 可能匹配服务器 A 的规则 "geosite:google",于是 DNS 查询首先发往服务器 A,然后回退到其他服务器。
查询模式
串行查询
serialQuery() 按顺序遍历排序后的客户端列表。第一个返回非空 IP 集合的客户端胜出。如果某个客户端返回错误,则尝试下一个。
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() 同时向所有排序后的客户端发起查询,然后使用基于分组的竞争策略收集结果:
分组:具有相同
policyID的相邻客户端组成一个分组。makeGroups()函数仅合并相邻且规则等价的客户端。分组竞争:在每个分组内,第一个成功的结果获胜(最小 RTT)。如果分组内所有成员都失败,则尝试下一个分组。
超时处理:对于启用了缓存的服务器,上下文超时会加倍(
c.timeoutMs * 2),使用context.WithoutCancel允许后台缓存更新。
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)
当 QueryStrategy 为 USE_SYS 时,DNS 系统在查询前探测实际的网络连接性:
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.go | DNS 结构体、New()、LookupIP()、sortClients()、串行/并行查询 |
app/dns/nameserver.go | Server 接口、Client 结构体、NewServer() 工厂、NewClient()、IP 过滤 |
app/dns/dnscommon.go | IPRecord、dnsRequest、buildReqMsgs()、parseResponse()、EDNS0 选项 |
app/dns/hosts.go | StaticHosts,用于 "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 结构。