Skip to content

DNS 服务器实现

Xray 实现了五种不同的 DNS 服务器类型以及一个 FakeDNS 伪服务器。它们都实现了 app/dns/nameserver.go 中定义的 Server 接口。服务器类型由 NewServer() 工厂根据命名服务器地址的 URL scheme 选择。

服务器分发逻辑

app/dns/nameserver.go 中的 NewServer() 函数路由服务器创建:

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 数据包穿越代理链。

服务器类型总结

类型结构体Scheme传输方式经调度器
UDPClassicNameServerIP 地址 / 无 schemeUDP
TCPTCPNameServertcp://TCP是(远程)/ 否(本地)
DoHDoHNameServerhttps://h2c://HTTP/2 POST是(远程)/ 否(本地)
DoQQUICNameServerquic+local://QUIC 流否(仅本地)
本地LocalNameServerlocalhost系统解析器
FakeDNSFakeDNSServerfakedns无(内存中)

CachedNameserver 模式

所有实际 DNS 服务器(UDP、TCP、DoH、DoQ)通过 app/dns/nameserver_cached.go 中定义的 CachedNameserver 接口共享通用的查询模式:

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 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
    end

UDP 服务器(ClassicNameServer

文件: app/dns/nameserver_udp.go

经典的 DNS-over-UDP 实现。它维护一个以 DNS 消息 ID 为键的待处理请求映射表。

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() 使用 buildReqMsgs() 构建 A 和/或 AAAA 请求消息
  2. 每个消息通过 dns.PackMessage() 打包并经 udp.Dispatcher 分发
  3. 响应通过 HandleResponse() 回调异步到达
  4. 遇到截断时,使用 EDNS0 OPT 资源(UDP 载荷大小 1350)重试查询
  5. 成功的响应传递给 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。存在两种变体:远程(经调度器)和本地(直连)。

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() 创建路由连接,通过 cnc.NewConnection() 转换为 net.Conn
  • 本地tcp+local://):使用 internet.DialSystem() 直接建立系统连接

线路格式: TCP DNS 在每条消息前附加 2 字节大端序长度前缀。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 来模拟 Chrome 的 TLS 指纹
  • h2c 变体(h2c://)完全跳过 TLS,使用明文 HTTP/2
  • 使用随机长度(100-300 字节)的 EDNS0 填充来掩盖查询大小模式
  • HTTP 请求包含 X-Padding 头部,使用随机 base62 填充
  • 自解析检测:如果 DoH 服务器试图解析自身的主机名,会引发错误

请求格式: POST 请求到 DoH URL,Content-Type: application/dns-messageAccept: application/dns-message。请求体为原始 DNS 线路格式。

连接模式:

  • 远程(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 查询通过 conn.OpenStreamSync() 打开一个新的 QUIC 流。消息格式使用 2 字节长度前缀,与 TCP DNS 相同。

本地服务器(LocalNameServer

文件: app/dns/nameserver_local.go

操作系统解析器的轻量包装。

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 地址。

go
type FakeDNSServer struct {
    fakeDNSEngine dns.FakeDNSEngine
}
  • 返回 TTL=1 的虚假 IP(防止下游缓存)
  • 通过 FakeDNSEngineRev0.GetFakeIPForDomain3() 支持双栈
  • dns.FakeDNSEngine 功能通过 core.RequireFeatures() 解析
  • 缓存始终禁用
  • option.FakeEnable 为 false 时在查询中跳过

DNS 流量路由

DNS 查询通过客户端的 tag 字段经 Xray 调度器路由。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"(DoH 为 "https",DoQ 为 "quic")和 SkipDNSResolve: true 以防止递归 DNS 解析。

实现要点

  • 所有 sendQuery() 实现在同时启用 IPv4 和 IPv6 时,会在并行 goroutine 中发送 A 和 AAAA 查询。

  • noResponseErrCh channel(容量 2)允许 sendQuerydoFetch() 调用方报告传输层错误,防止在 pubsub 订阅者上无限等待。

  • 请求 ID 生成方式不同:UDP 使用原子计数器生成唯一 ID(多路复用响应所必需),而 DoH 和 DoQ 始终使用 ID 0(因为每个查询有独立的 HTTP 请求或 QUIC 流)。

  • TCP 和 DoH 服务器使用父上下文的 context.WithDeadline,而 UDP 使用周期性清理定时器(8 秒)进行超时管理。

  • DNS 结构体上的 IsOwnLink() 方法检查当前入站 tag 是否与任何 DNS 客户端的 tag 匹配,以防止 DNS 解析循环。

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