DNS 服务器实现
Xray 实现了五种不同的 DNS 服务器类型以及一个 FakeDNS 伪服务器。它们都实现了 app/dns/nameserver.go 中定义的 Server 接口。服务器类型由 NewServer() 工厂根据命名服务器地址的 URL scheme 选择。
服务器分发逻辑
app/dns/nameserver.go 中的 NewServer() 函数路由服务器创建:
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 数据包穿越代理链。
服务器类型总结
| 类型 | 结构体 | Scheme | 传输方式 | 经调度器 |
|---|---|---|---|---|
| UDP | ClassicNameServer | IP 地址 / 无 scheme | 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)通过 app/dns/nameserver_cached.go 中定义的 CachedNameserver 接口共享通用的查询模式:
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 as 上游服务器
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 线路协议
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 实现。它维护一个以 DNS 消息 ID 为键的待处理请求映射表。
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()使用buildReqMsgs()构建 A 和/或 AAAA 请求消息- 每个消息通过
dns.PackMessage()打包并经udp.Dispatcher分发 - 响应通过
HandleResponse()回调异步到达 - 遇到截断时,使用 EDNS0 OPT 资源(UDP 载荷大小 1350)重试查询
- 成功的响应传递给
cacheController.updateRecord()
请求清理: task.Periodic 每分钟运行一次,淘汰超过 8 秒的请求。
EDNS0 客户端子网: 如果配置了 clientIP,则追加 EDNS0 Subnet 选项,IPv4 使用 /24,IPv6 使用 /96。
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()创建路由连接,通过cnc.NewConnection()转换为net.Conn - 本地(
tcp+local://):使用internet.DialSystem()直接建立系统连接
线路格式: TCP DNS 在每条消息前附加 2 字节大端序长度前缀。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来模拟 Chrome 的 TLS 指纹 - h2c 变体(
h2c://)完全跳过 TLS,使用明文 HTTP/2 - 使用随机长度(100-300 字节)的 EDNS0 填充来掩盖查询大小模式
- HTTP 请求包含
X-Padding头部,使用随机 base62 填充 - 自解析检测:如果 DoH 服务器试图解析自身的主机名,会引发错误
请求格式: POST 请求到 DoH URL,Content-Type: application/dns-message,Accept: application/dns-message。请求体为原始 DNS 线路格式。
连接模式:
- 远程(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 查询通过 conn.OpenStreamSync() 打开一个新的 QUIC 流。消息格式使用 2 字节长度前缀,与 TCP DNS 相同。
本地服务器(LocalNameServer)
文件: app/dns/nameserver_local.go
操作系统解析器的轻量包装。
type LocalNameServer struct {
client *localdns.Client
}- 缓存始终禁用(
IsDisableCache()返回true) - 自动添加
geosite:private域名规则(本地 TLD 和无点域名) - 作为唯一服务器(默认情况)添加时,使用全局
ipOption包装为Client - 底层通过
localdns.Client使用 Go 的net.Resolver
FakeDNS 服务器(FakeDNSServer)
文件: app/dns/nameserver_fakedns.go
不是真正的 DNS 服务器——它从配置的地址池生成虚假 IP 地址。
type FakeDNSServer struct {
fakeDNSEngine dns.FakeDNSEngine
}- 返回 TTL=1 的虚假 IP(防止下游缓存)
- 通过
FakeDNSEngineRev0.GetFakeIPForDomain3()支持双栈 dns.FakeDNSEngine功能通过core.RequireFeatures()解析- 缓存始终禁用
- 当
option.FakeEnable为 false 时在查询中跳过
DNS 流量路由
DNS 查询通过客户端的 tag 字段经 Xray 调度器路由。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"(DoH 为 "https",DoQ 为 "quic")和 SkipDNSResolve: true 以防止递归 DNS 解析。
实现要点
所有
sendQuery()实现在同时启用 IPv4 和 IPv6 时,会在并行 goroutine 中发送 A 和 AAAA 查询。noResponseErrChchannel(容量 2)允许sendQuery向doFetch()调用方报告传输层错误,防止在 pubsub 订阅者上无限等待。请求 ID 生成方式不同:UDP 使用原子计数器生成唯一 ID(多路复用响应所必需),而 DoH 和 DoQ 始终使用 ID 0(因为每个查询有独立的 HTTP 请求或 QUIC 流)。
TCP 和 DoH 服务器使用父上下文的
context.WithDeadline,而 UDP 使用周期性清理定时器(8 秒)进行超时管理。DNS结构体上的IsOwnLink()方法检查当前入站 tag 是否与任何 DNS 客户端的 tag 匹配,以防止 DNS 解析循环。