Featured image of post 『Blog』Dive into Clash DNS

『Blog』Dive into Clash DNS

深入浅出地介绍 Clash DNS 的工作原理

简介

逐行阅读源码,超!硬!核!的 Clash DNS 底层原理详解!

(incoming…)

实验环境

操作系统Windows 11
系统DNS192.168.31.1
网关IP192.168.31.1
子网IP192.168.31.178
代理核心Clash.Meta v1.15.0
引导UIclash-verge v1.3.3
  1. Protocol: tuic v5

    1
    2
    3
    4
    
    proxies:
      - { name: "tunic", type: tuic, udp-relay-mode: quic, congestion-controller: bbr, alpn: ['h3', 'spdy/3.1'], reduce-rtt: True, max-udp-relay-packet-size: 1464, server: *, port: *, uuid: *, password: *, ip: * }
    proxy-groups:
      - { name: PROXY, type: select, proxies: ["tunic"] }
    
  2. RULE-SET 来自规则上游 Loyalsoldier/clash-rules

    当正文中没有明确说明 rules 时使用如下配置:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    
    rule-providers:
      # -- skip --
    rules:
      - GEOSITE,category-scholar-!cn,PROXY
      - GEOSITE,category-ads-all,REJECT
      - GEOSITE,youtube,PROXY
      - GEOSITE,google,PROXY
      - GEOSITE,cn,DIRECT
      - GEOSITE,private,DIRECT
      - GEOSITE,steam@cn,DIRECT
      - GEOSITE,category-games@cn,DIRECT
      - GEOSITE,geolocation-!cn,PROXY
      - GEOIP,private,DIRECT,no-resolve
      - GEOIP,telegram,PROXY
      - GEOIP,CN,DIRECT
      - DST-PORT,80/8080/443/8443,PROXY
      - MATCH,DIRECT
    

实验方法

🦉实验可安全复现

  1. 分别在规则模式,全局模式,直连模式下使用预定义的 dns 实验配置访问域名:

    • api.dida365.com

      命中规则 geosite:cn

    • api.dida36768990.com

      一个随便敲的域名,假设它不存在。显然,这是个不存在于各个 RULE-SET 中的 target。

    • www.google.com

      从上至下,命中首条域名代理规则。

  2. 切换到 TUN 模式再测一遍

  3. Recode DNS configuration, logger, wireshark trace

    记录请求的触发顺序,阐述 clash DNS 通信的底层逻辑。

  4. 无特殊说明统一使用 clash 代称 Clash 和 Clash.Meta

  5. 实验在 Win PC 执行,而非软路由等嵌入式设备

  6. 每轮实验结束后清除所有 clash 连接, ipconfig \flushdns 清除 DNS 缓存

  7. 实验中使用 clash-verge 引导 Clash.Meta 代理核心,UI 保持出厂设定


🪖需要注意:Clash DNS Object 有初始值,clash 会用硬编码的初始化参数覆盖我们缺省的字段,具体默认值可看上文的 DNS 默认配置 以及 UnmarshalRawConfig

实验按以下模块依次行进:

  1. config.yaml 中的 dns 配置字段
  2. wireshark 抓包,过滤条件是 dns && ip.dst==192.168.31.178
  3. 连接日志(可省略)

配置篇

引入 default-nameserver & nameserver

💡前者用于解析后者的域名,后者用于解析我们要访问的域名。

规则模式 - 对照组 | 默认配置

仅设置 enable 字段,此时采用初始化的 DNS 配置

1
2
dns:
  enable: true

可以看到,核心先并发请求 default-nameserver 中配置的四条 DNS IP 去解析 doh.pub (再使用 https://doh.pub/dns-query 这条 DOH nameserver 配置去解析 api.dida365.com)。

DOH 行进时,域名查询被封装在 HTTPS 请求中,使其本质上成为了 HTTP 协议类型的加密流量。这种类型的请求无法使用 protocol==dns 进行捕获,如 wireshark 的截图所示,看不到查询 api.dida365.com 的响应纪录。

1
[TCP] 127.0.0.1:64007(msedge.exe) --> api.dida365.com:443 match GeoSite(cn) using DIRECT

可以得出如下阶段性结论:

  1. 访问域名时遇到直连域名规则,从本地发起 DNS 请求

    api.dida365.com 是一个在 geosite:cn 数据库中的规则,其对应了我们的出站策略 DIRECT(详见上文的 rules),也即,下面这两种情况是等价的。

    1
    2
    3
    
    rules:
      - GEOSITE,CN,DIRECT # A
      - DOMAIN,api.dida365.com,DIRECT # B
    
  2. default-nameserver 仅用来解析 nameserver 字段中配置的域名

    这也说明了 default-nameserver 为什么只能写 IP 而不能写域名,这会遇到先有鸡还是先有蛋的问题。但值得一提的是,default-nameserver 支持 HTTPS 写法。

    这也意味着,如果 nameserver 中没有配置域名形式的 DOH/DOT/DOQ,则 default-nameserver 对应的业务代码不会被触发。(具体情况看下文的测试)

  3. 通过 nameserver 解析我们要访问的域名

规则模式 - 仅设置 nameserver 为 DNS IP

从这里开始我们就不贴规则匹配的日志了,结果都和对照组一样。我们在仅修改 DNS 配置的情况下,同一 target 的出站策略不受影响。

1
2
3
4
dns:
  enable: true
  nameserver:
    - 223.5.5.5

行进步骤如下:

  1. 浏览器访问 api.dida365.com ,请求通过系统代理进入 clash

  2. 使用 nameserver 解析我们要访问的域名

    可以看到我们向(人为配置的)223.5.5.5 发起了明文 DNS 请求并收到了查询响应。其中,除了我们访问的 api.dida365.com ,还能看到其他驻台程序发起的 DNS 请求。

    显然,这与对照组的结果不一致。

    具体来说 ,我们在对照组(默认配置)下使用 DOH https://doh.pub/dns-query 发送域名解析请求,这个请求是一个封装在 HTTPS 数据包中的加密申请,我们无法在过滤条件为 dns 的筛选中捕获到这条解析请求。而本组实验设置中使用了未经加密的申请,也即 udp://223.5.5.5:53(行进源码自动补全为 URL),这是一条命中 dns 过滤规则的请求,我们不仅可以捕获到它,还能看见它的 Message/Information,即,要访问/要解析的站点。

  3. 由于我们未配置域名形式的 DOH,所以 default-nameserver 相关行进代码未被触发。

规则模式 - 仅设置 nameserver 为 DOH: IP

1
2
3
4
dns:
  enable: true
  nameserver:
    - https://223.5.5.5/dns-query

行进步骤如下:

  1. 浏览器访问 api.dida365.com ,请求通过系统代理进入 clash

  2. 使用 nameserver 解析我们要访问的域名

    我们向 IP 形式的 DOH 发起了对 api.dida365.com 域名解析请求,请求被加密到 HTTPS 数据包中,通过 dns 筛选无法捕获。

  3. 由于我们未配置域名形式的 DOH,所以 default-nameserver 相关行进代码未被触发。

  4. 当我们等待一段时间后,我们可以陆续收到来自系统DNS(网关)返回的 DNS 查询响应。按理说,我们的请求不是被加密了吗?要响应也应该是 HTTPS 类型的协议,再不济也应该是阿里服务器返回的响应啊?(也即 dns && ip.src==223.5.5.5)怎么会有来自本地运营商发来的 DNS 查询响应呢?

    这涉及到 Windows 操作系统级的 DNS 查询组策略,我们后文细说。

规则模式 - 仅设置 nameserver 为 DOH: domain

1
2
3
4
dns:
  enable: true
  nameserver:
    - https://dns.alidns.com/dns-query

是不是熟悉的感觉又回来了~🐧

行进步骤如下:

  1. 浏览器访问 api.dida365.com ,请求通过系统代理进入 clash

  2. 使用 nameserver 解析我们要访问的域名

    与对照组的情况一直,这是个加密请求,后文不再赘述。

  3. 我们配置了域名形式的 DOHdefault-nameserver 相关代码开始执行。

引入 namesever-policy

💡优先级高于 nameserver

规则模式 - 同时设置 nameserver-policynameserver

1
2
3
4
5
6
7
dns:
  enable: true
  nameserver:
    - 223.5.5.5
  nameserver-policy:
    "api.dida365.com":
      - 1.0.0.1
  1. 浏览器访问 api.dida365.com ,请求通过系统代理进入 clash

  2. 命中规则,使用 nameserver-policy 解析域名 api.dida365.com

    当我们访问 api.dida365.com 时优先使用 1.0.0.1 进行域名解析。除此之外所有的 DNS 请求都走 223.5.5.5,比如随后响应的cdn.dida365.com

  3. 由于 nameservernameserver-policy 都不存在域名形式的 DOH,default-nameserver 相关代码不启动

规则模式 - 混用 DOH

1
2
3
4
5
6
7
dns:
  enable: true
  nameserver:
    - 119.29.29.29 # 腾讯 IPv4 DNS
  nameserver-policy:
    "api.dida365.com":
      - https://dns.alidns.com/dns-query

一路看到这你应该能自己总结行进步骤了~🐧

  1. 浏览器访问 api.dida365.com ,请求通过系统代理进入 clash

  2. 命中规则,使用 nameserver-policy 解析域名 api.dida365.com

    nameserver-policy 优先生效,里面有域名形式的 DOH,default-nameserver 启动 ;解析 api.dida365.com 的 DNS 请求被加密成 HTTPS,在 dns 筛选中不显示。

    除此之外所有的 DNS 请求都走 119.29.29.29,比如随后响应的cdn.dida365.comnameserver 中没有域名形式的 DOH,default-nameserver 不启动。

规则模式 - 仅设置 nameserver-policy 为 DOH: IP

1
2
3
4
5
dns:
  enable: true
  nameserver-policy:
    "api.dida365.com":
      - https://223.5.5.5/dns-query
  1. 浏览器访问 api.dida365.com ,请求通过系统代理进入 clash

  2. 命中规则,使用 nameserver-policy 解析域名 api.dida365.com

    nameserver-policy 生效,里面没有域名形式的 DOH,default-nameserver 不启动 ;解析 api.dida365.com 的 DNS 请求被加密成 HTTPS,在 dns 筛选中不显示。

    其他的 DNS 请求都走 nameserver ,也即默认值 https://doh.pub/dns-query ,这是一条域名形式的 DOH,default-nameserver 启动。

    显然,走 DOH 的 DNS 请求被加密,在 wireshark 结果中看不到 target 响应

规则模式 - 仅设置 nameserver-policy 为 DOH: domain

1
2
3
4
5
dns:
  enable: true
  nameserver-policy:
    "api.dida365.com":
      - https://dns.alidns.com/dns-query
  1. 浏览器访问 api.dida365.com ,请求通过系统代理进入 clash

  2. 命中规则,使用 nameserver-policy 解析域名 api.dida365.com

    nameserver-policy 生效,里面有域名形式的 DOH,default-nameserver 启动 ;解析 api.dida365.com 的 DNS 请求被加密成 HTTPS,在 dns 筛选中不显示。

    其他的 DNS 请求都走 nameserver ,结果同上一则实验一致。

引入 DNS 分流

💡你如果是一个 Clash 骨灰级玩家或是项目的核心贡献者,也许有这样的感悟:DNS 是 clash 类项目设计最为精妙也是最复杂的机制,甚至没有之一。

规则模式 - 域名规则的解析

添加出站规则,将 api.dida365.com 设为 #PROXY 出站:

1
2
3
4
5
rules:
  # 在对照规则之前该条添加规则
  - DOMAIN,api.dida365.com,PROXY
  # -- skip  --
  - MATCH,PROXY

使用 8.8.8.8 标记 api.dida365.com ,使用 223.5.5.5 标记其他请求:

1
2
3
4
5
6
7
8
9
dns:
  enable: true
  nameserver:
    - https://dns.alidns.com/dns-query
    - 223.5.5.5
  nameserver-policy:
    "api.dida365.com":
      - https://dns.google/dns-query
      - 8.8.8.8

在开始该组实验前,我们可以根据上文实验结果提出如下假设:

<A> ✅ 如果浏览器向 api.dida365.com 发出请求,必然触发 nameserver-policy 的域名解析任务;nameserver-policy 内有域名形式的DOH,必然会使用 default-nameserver(并发)解析 dns.google ,最后使用解析到的 DOH IP 和 8.8.8.8 (并发)解析 api.dida365.com

<B> ✅ (假设没有发生 IP 规则匹配)其他的域名直连请求使用 nameserver 进行解析,显然 nameserver 组内也有域名形式的 DOH,必然使用 default-nameserver (并发)解析 dns.alidns.com ,使用解析到的 DOH IP 和 223.5.5.5 并发查询我们要访问的域名。

抓包结果如下图所示(隐去若干条杂质数据):

🏴‍☠️ 我们先在这个组实验中抛出 <DNS泄漏> 的概念,具体内容后文细说。

可以得出如下观察纪录:

首先,我们能观察到 default-nameserver 开始解析 dns.alidns.com 域名,说明 nameserver 被触发,说明有其他域名直连请求命中了 DIRECT 域名规则(假设没有发生 IP 规则匹配)。

而当我们不间断地请求 api.dida365.com 时,我们为其配置的 nameserver-policy 并未生效,我们在 <A> 中假象的逻辑链没有发生,也即,clash 并未向 default-nameserver 发起针对 dns.google 的域名解析请求,或者说,本地没有发起对 api.dida365.com 的直连访问请求。

显然,我们将 api.dida365.com 设置为 PROXY 出站后,访问目标站点的工作交由远程服务器完成,自然地,域名解析的工作也由远程服务器完成。本地做的,只是将访问 api.dida365.com 的代理请求呈送至远程服务器,也即,命中 PROXY 域名规则后,clash 会直接跳过 DNS 步骤向远程服务器呈送数据包。

规则模式 - 域名规则的解析 - 验证

  • 验证 <1>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
dns:
  enable: true
  nameserver:
    - 223.5.5.5
  nameserver-policy:
    "api.dida36768990.com":
      - 8.8.8.8
rules:
  - DOMAIN,api.dida36768990.com,DIRECT
  # -- skip --
  - MATCH,PROXY
  1. 浏览器访问 api.dida36768990.com,请求通过系统代理进入 clash

  2. 命中规则,使用 nameserver-policy 解析域名 api.dida365.com

    nameserver-policy 生效,里面没有域名形式的 DOH,default-nameserver 不启动,直接使用 8.8.8.8 解析 api.dida36768990.com

    显然,这是一个不存在的域名,DNS 响应出现 no such name 错误。

  • 验证 <2>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
dns:
  enable: true
  nameserver:
    - 223.5.5.5
  nameserver-policy:
    "api.dida36768990.com":
      - 8.8.8.8
rules:
  # 在对照组之前添加规则
  - DOMAIN,api.dida36768990.com,DIRECT
  - DOMAIN,api.dida365.com,DIRECT
  # -- skip --
  - MATCH,PROXY
  • 情况1:浏览器访问 api.dida365.com ,请求通过系统代理进入 clash

    未命中 nameserver-policy,切换至 nameserver ,组内没有域名形式的 DOH,default-nameserver 不启动,直接向 223.5.5.5 发起域名解析请求(绿色记录)。

    其他命中域名直连规则的请求也是用 nameserver 进行域名解析(橙色记录)。

    相关 DNS 请求均未被加密,可以看到抓包记录。

  • 情况2:浏览器访问 api.dida36768990.com,请求通过系统代理进入 clash

    命中 nameserver-policy,组内没有域名形式的 DOH,default-nameserver 不启动,直接向 8.8.8.8 发起域名解析请求。显然,这是一个不存在的域名,DNS 响应出现 no such name 错误(黄色记录)。

规则模式 - IP 规则的解析

首先修改出站规则,分别使用 223.5.5.5 和 180.76.76.76 标记 api.dida365.comapi.dida36768990.com 其余直连请求使用 119.29.29.29。这里为了 trace 纪录干净整洁便于原理分析,就不使用域名 DOH 了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
dns:
  enable: true
  nameserver:
    - 119.29.29.29
  nameserver-policy:
    "api.dida365.com":
      - 223.5.5.5
    "api.dida36768990.com":
      - 180.76.76.76
rules:
  - GEOIP,CN,PROXY
  - DOMAIN,api.dida36768990.com,DIRECT
  - DOMAIN,api.dida365.com,DIRECT
  - MATCH,PROXY
  1. 浏览器访问 api.dida365.com ,请求通过系统代理进入 clash

  2. 遇到 GEOIP 代理规则,发起本地 DNS 查询

    很多小伙们困惑的点就在这了!根据我们上文提到的结论,访问域名时遇到 IP 规则会进行DNS 解析,遇到代理规则会将请求呈送至远程服务器。那这种特殊情况到底是先进行 DNS 解析还是直接将数据包呈送到远程服务器跳过DNS解析呢?

    访问域名时遇到 IP 规则,先进行本地 DNS 解析,将要请求的域名解析成 IP,再与 IP 规则集中的 IP 进行匹配,如果命中,出站规则生效,否则进入下一个规则集。

    访问域名时遇到域名规则,如果是直连出站,则由本地发起 DNS 查询;如果是代理出站,则跳过 DNS 查询并将代理请求呈送至远程服务器。

  3. 命中规则,使用 namesever-policy 解析域名 api.dida365.com

    如下图所示,本地向 233.5.5.5 发起了针对 api.dida365.com 的 DNS 请求,这验证了我们前文说的内容。

规则模式 - IP 规则的解析 - 原理剖析🏴‍☠️

先阅读上一节,否则你可能看不懂这里写的东西。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
dns:
  enable: true
  nameserver:
    - 119.29.29.29
  nameserver-policy:
    "api.dida365.com":
      - 223.5.5.5
    "api.dida36768990.com":
      - 180.76.76.76
rules:
  - GEOIP,CN,PROXY
  - DOMAIN,api.dida36768990.com,DIRECT
  - DOMAIN,api.dida365.com,DIRECT
  - MATCH,PROXY
  1. 浏览器访问 api.dida36768990.com,请求通过系统代理进入 clash

  2. 遇到 GEOIP 代理规则,发起本地 DNS 查询

  3. 命中规则,使用 namesever-policy 解析域名 api.dida36768990.com

    组内没有域名形式的 DOH,default-nameserver 不启动,直接使用 180.76.76.76 进行域名解析。显然,我们要访问的是一个不存在的域名,自然无法得到对应的 IP(如下图绿标的 no such name信息),也即,这条通路报错了。

那么 clash 会如何处理这个请求呢?

clash ParseRule 的行进策略是 for-loop with switch-case,节取代码如下:

1
2
3
4
5
6
7
// 节取
for idx, line := range rulesConfig {
    parsed, parseErr := ParseRule(ruleName, payload, target, params, subRules)
    if parseErr != nil {
        return nil, fmt.Errorf("%s[%d] [%s] error: %s", format, idx, line, parseErr.Error())
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 节取
func ParseRule(ruleName, payload, target string, params []string, subRules map[string][]C.Rule) (parsed C.Rule, parseErr error) {
    switch ruleName {
        case "DOMAIN":
            parsed = RC.NewDomain(payload, target)
        case "GEOSITE":
            parsed, parseErr = RC.NewGEOSITE(payload, target)
        case "GEOIP":
            noResolve := RC.HasNoResolve(params)
            parsed, parseErr = RC.NewGEOIP(payload, target, noResolve)
        case "MATCH":
            parsed = RC.NewMatch(target)
            parseErr = nil
        default:
            parseErr = fmt.Errorf("unsupported rule type %s", tp)
    }
    if parseErr != nil {
        return nil, parseErr
    }
    return
}

clash 从上至下逐行读取 rules,每个规则集都会进入到一个 case 子业务链路,我们这里进入的是 GEOIP 的 case,它首先会识别是否存在 no-resolve 标识,再以此决定 NewGEOIP 的具体行为。GEOIP 的规则匹配流程此处不过多赘述,感兴趣的小伙伴可以自行翻阅源码。

那么,根据我们先前的讨论,这里的 parseErr 必然是“有内容的”,也即这个错误传递到上层的 for 循环后会中断循环,使得程序不会行进到下一条 rule,也即 DOMAIN,api.dida36768990.com,DIRECT

换句话说,当我们试图将 api.dida36768990.com所对应的 IP 与规则集 GEOIP,CN,PROXY 进行配对时遇到了错误,这个错误首先中断匹配行进,打印错误日志,随后向上一直传导至 main 接口,沿途触发所有的 warning + 级别的日志警告。

规则模式 - IP 规则的解析 - 验证

  • GEOIP no-resolve only, REQUEST GET api.dida365.com + api.dida36768990.com

    跳过 GEOIP 的本地域名解析,直接进入下一个规则集的匹配

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    dns:
      enable: true
      nameserver:
        - 119.29.29.29
      nameserver-policy:
        "api.dida365.com":
          - 223.5.5.5
        "api.dida36768990.com":
          - 180.76.76.76
    rules:
      - GEOIP,CN,DIRECT,no-resolve
      - MATCH,PROXY
    

  • GEOIP no-resolve && DOMAIN, REQUEST GET api.dida365.com + api.dida36768990.com

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    dns:
      enable: true
      nameserver:
        - 119.29.29.29
      nameserver-policy:
        "api.dida365.com":
          - 223.5.5.5
        "api.dida36768990.com":
          - 180.76.76.76
    rules:
      - GEOIP,CN,DIRECT,no-resolve
      - DOMAIN,api.dida36768990.com,DIRECT
      - DOMAIN,api.dida365.com,DIRECT
      - MATCH,PROXY
    

    跳过「访问域名遇到 GEOIP 规则本该进行的本地 DNS 解析」直接进入下一个规则集的匹配,接下来的两条直连域名规则的情况属于已讨论过的正常情况,也即,发起本地 DNS 请求。

  • 规则模式 - 向全局过渡

    修改 rules

    1
    2
    
    rules:
      - MATCH,PROXY
    

    这近似于全局(代理)模式,所有经由 clash 的请求都被呈递到远程服务器,DNS解析由远程服务器完成,本地扫不到经由 clash DNS 的请求纪录。

    全局模式 - 全局代理,全局直连与全局拒绝

    该模式下,我们可以指定所有请求都 代理/DIRECT/REJECT。其中,代理可以细分为某个代理组或某个代理节点。

    代理:所有经由 clash 的请求都被呈递到远程服务器,DNS解析由远程服务器完成,本地扫不到 DNS 请求纪录。具体由哪个服务器处理请求取决于你的配置。

    DIRECT:所有经由 clash 的请求都从本地发出,DNS解析行为由 dns 字段定义的策略决定,本地可以扫到未加密的纪录。

    REJECT: 所有经由 clash 的请求都被甩入黑洞,此时你访问任何网站都会显示 ERR_CONNECTION_CLOSED 的警告,虽然这并不意味着你没法上网,但至少你浏览器是没法用了。🥹同全局代理,此时 DNS 配置不起作用。

    如果与上一节内容作比较,你可以这样理解:

    1
    2
    3
    4
    
    rules:
      - MATCH,PROXY  # 全局代理(使用名为 PROXY 的代理组)
      - MATCH,DIRECT # 全局直连
      - MATCH,REJECT # 全局拒绝
    
  • Best Practices - DNS 分流配置

    想啥呢🎃才开始进阶怎么会有最佳实践配置

    想啥呢🐧我们还没开始 <困难>章节内容的介绍~

    不过,我们是时候来个阶段性的最佳实践总结了,后续介绍的内容也不会和如下结论冲突。

    1. 编排 rules 规则时,先写域名匹配再写 IP 匹配,最后 MATCH 兜底。

      默认出站选择 PROXY 还是 DIRECT 取决于你的性癖上网习惯,进而决定选择 白名单 还是 黑名单 的编排方案。

      • MATCH,PROXY 兜底,建议遵从如上 rules 最佳实践的前提下,先编排 PROXY 出站规则再编排 DIRECT 出站规则
      • MATCH,DIRECT 兜底,反之。
    2. 优先使用 RULE-SET,只需添加未在上游规则集中出现的规则。

      尽管目前这些规则集至少有几十万条数据,但在精心设计的 Mapping 策略下,规则匹配的耗时是忽略不计的。

      此外,你可能需要将这条规则 RULE-SET,applications,DIRECT 添加到你的 rules 首行。

    3. 当访问域名遇到 IP 规则域名直连规则时,本地发起 DNS 请求。

      需要注意的是,你添加的 classical RULE-SET 可能也包含域名直连规则以及 IP 规则

    4. clash DNS Mapping

      • 使用 nameserver-policynameserver 解析我们即将访问的域名,前者优先启动。
      • 需要使用 default-nameserver 解析上述两个策略组中的域名,也即,default-nameserver 需要使用 IP 写法(可以是 IP 形式的 DOH),避免出现鸡蛋问题。
      • 这三个策略组的网络请求是并发行进的。如果存在多个并发查询,clash 优先采用最速响应的 IP 作为域名的查询结果。
      • 不必修改(覆盖)默认的 default-nameserver,而 nameserver-policynameserver 尽可能使用 IP 形式的 DOH,省去这个间接的解析步骤。
      • 在设置 nameserver-policynameserver 时使用 DOH,而非 DOT/DOQ。

    我们可以凭借上述特性组合出「国外DNS」和「直连DNS」的概念,你可以说这是一个精妙的巧合,但也可以说这是社区智慧的结晶。

    Public DNS - 已知的 DNS 提供商

原理篇

💡Let’s analyze this systematically.

Clash flow - DNS

流程图

Clash flow - Proxy

流程图

泄漏篇

DNS 泄漏 - 概念界定

  1. 用户开启 VPN 后,正常网上冲浪产生的 DNS 请求被 本地 上游 ISP 捕获。
  2. 因此,本地上游 ISP 知道 <某人> 要解析的 <某域名>

DNS 泄漏 - 争议点

VPN 的 DNS 泄漏

在正常情况下,开启 VPN 后,VPN 软件将用户的「访问请求」呈送至远程服务器,访问网站的工作由远程服务器完成,也即,DNS 请求由远程服务器发出。

此时出现了一些不可抗力因素,用户向目标网站发起了直连请求,或者说,本地发起了向「系统网关 → 上游 ISP」的 DNS 查询。有可能是 VPN 软件没有劫持到这个请求,也有可能是用户配置出了问题,总之就是 DNS 请求不仅发往了 VPN 提供的 DNS ,也发往了本地 ISP。

In the case,尽管你配置了 DOH,但作为解析 DNS 请求的上游 ISP 它必然是能看到你要访问的 网址(域名),不然它没法给你查询域名对应的 IP(DNS基本原理不过多赘述)。

Proxy 的 DNS 泄漏

clash 等代理核心出现了规则模式的设定,在合理的配置下:

  1. 访问国内站点(如 geosite:cn),直接从本机发起请求。
  2. 访问我们标记的站点(如 youtube),则将请求呈送至远程服务器,代理访问。

绝大多数玩家使用的都是规则模式下的系统代理,例如:

1
2
3
4
5
6
7
8
rules:
  - DOMAIN-SUFFIX,youtube.com,PROXY
  - GEOSITE,CN,DIRECT
dns:
  enable: true
  nameserver-policy:
    "geosite:cn":
      - "https://223.5.5.5/dns-query"

在这种常见配置下,我们直连访问国内站点(如 bilibili)所产生的 DNS 请求,必然被国内DNS 提供商捕获。这种算 DNS 泄漏 吗?我觉得不算。

根据我们上文提到的定义,DNS泄漏指的是我们使用代理访问 www.youtube.com ,却从本地发送 DNS 请求到国内 ISP 查询 www.youtube.com 对应的 IP ,因此上游知道了你曾访问油管。

显然,在配置正确的情况下不可能出现这种事故 根据我们上文的实验结论,域名请求遇到域名规则,命中规则后,如果是代理出站,我们会将访问请求呈送至远程服务器,DNS 查询由远程服务器完成。

也即,当你配置了 DOMAIN-SUFFIX,youtube.com,PROXY 这条规则时,本地不会发起对 +.youtube.com 的域名解析。(假设已禁用DNS策略组)

这与我们先前提到的 VPN 行为模式不同。我们的意图在于:

  1. 我们启用了规则模式,旨在避免远程服务器处理我们对国内网站的访问请求。我们希望访问国内网站而从本地发起的 「DNS 请求」经过本地运营商,以此获得更低的网络延迟。
  2. 不希望本地运营商捕获到我们对标记网点(如 www.youtube.com)的访问请求。

换句话说,一个 DNS 请求过来,运营商必然知道有人正在访问 www.youtube.com,而代理访问的本质在于不让运营商知道访客的真实身份。

DNS 泄漏 - 检测工具

DNS 泄漏 - 检测原理

如果你还不知道什么是 DNS 泄漏可以从前文看起,泄漏的定义大致如下图所示:

  1. ✅直连访问 bilibili.com ,由本地 PC 向上游 DNS 发送查询
  2. ✅代理访问 google.com,由远程服务器向国外 DNS 发送查询
  3. ❌直连访问 google.com,由本地 PC 向上游 DNS 发送查询

实验开始前,编排如下规则:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
dns:
  enable: true
  nameserver:
    - 223.5.5.5
  nameserver-policy:
    "ipleak.net":
      - 1.0.0.1
rules:
  - GEOSITE,CN,DIRECT
  - GEOIP,CN,DIRECT
  - MATCH,PROXY

ipleak.net 爲例说明 DNS 泄漏检测的工作原理。

当你访问 DNS 泄漏检测的站点后,发生了如下事件:

  1. ipleak.net 后端服务器向前端页面不断推送新的数据包,这些数据包的 Request URL 都是 +.ipleak.net 形式,例如:

  2. 浏览器发起 Request并构造 DNS 请求查询这些域名对应的 IP

    虽然这些域名是随机生成的,但它们都被正确解析到有效的后端端点,我们可以通过 DNS 查询到 Request Domain 对应的(CDN)IP。

    显然,这些随机生成的域名不可能存在于任何 GEOSITE,RULE-SET 数据库中(项目维护者也不会添加这些特殊的域名)也就是说,这些访问请求进入 clash 后,走的一定是兜底的 MATCH,PROXY 规则(除非你把 ipleak.net 这个域名配置到规则里了🥹)。

    我们通常会把兜底规则写在最后一行,意味着每个访问请求都会把你的 rules 扫一遍。在这个过程中,沿途遇到的首条 IP 规则(例如我们配置的 GEOIP)会触发本地 DNS 解析,「你配置的 DNS 的上游DNS」以及最终流向的「权威域名服务器」的 Server IP 会被域名检测网站记录下来,最终打印到前端页面上。

    访问请求最后命中 MATCH,PROXY 代理出站规则,所以远程服务器必然发起 DNS 查询请求,也即,在开启代理的情况下,前端页面会打印出落地机所在区域的国旗以及落地机上游 ISP DNS 信息 。

  3. 直连访问 - 正常情况

    如果你没理解我刚说的东西,你可以尝试不使用代理直接访问 ipleak.net,你可以看到页面被国旗刷屏了。这些 IP 来自你选用的 DNS 以及你上游的 ISP DNS。

  4. 代理访问 - 正常情况

    DNS 请求由远程服务器发起,远程服务器也会有自己的系统 DNS 和上游 ISP,在没有特殊设计网关的情况下,你能看到 ipleak.net 页面上不断刷出远程服务器所在地区的国旗以及由远程服务器发起的 DNS 请求经过的 server IP。

    如果你的远程服务器网络环境比较特殊,你会看到来自多个国家和地区的 server IP。

  5. 代理访问 - 异常情况

    如果检测站点能够通过记录 DNS server IP 的方式返回发送端 PC 的真实所在地,说明发生了 DNS 泄漏。

    这里加了超长的定语:

    • ✅本地没有发起 DNS 查询,不可能存在泄漏
    • 🚫本地发起 DNS 查询,使用系统 DNS 或国内 DNS,能够通过三要素定位还原出 zero 发送端的大致位置。例如 ipleak.net 等公有服务提供的粗糙定位能够显示国旗,但显然有更精密的技术能够做到经纬度级别的定位。
    • 👁️‍🗨️本地发起 DNS 查询,但使用的是经过特殊设计的 DNS 或其他地区的 DNS,不能或几乎不可能通过三要素定位还原出 zero 发送端的大致位置。

    🌀迷思与争议:

    1. ipleak.net出现了国旗,我的配置出现致命错误了吗?

      ipleak.net 出现了你所在地的国旗,并不代表你访问 youtube google 的请求会被本地运营商截获。我们上文提到过(如果你使用的是本文提到的配置或是 meta 懒人配置),类似访问 youtube 的请求在 IP 规则之前必然命中 PROXY 出站规则, 本地不会产生对 youtube.com 的 DNS 解析请求,也就不会有被本地运营商截获这一说。

      如果你只关注访问 youtube google 等网站的意图数据漏没漏,那你被 ipleak.net 等一众检测站点爆菊了也无伤大雅。

      但必须说明的是,出现了国旗,说明你向上游 ISP 发起了针对「意外站点」的 DNS 请求,ipleak 能够还原你的所在地,这属于 DNS 泄漏的范畴。 如果有某个网站借助 ipleak 等检测服务追溯发送端的大致服务区,你大概率会被精确到市区级运营商。

    2. ipleak.net没有出现国旗,我的配置正常吗?

      如果你用的是 VPN,你的配置正常。但你用的是 clash,这个问题存在争议。

      例如,访问 youtube.comrules 里出现了 DOMAIN-SUFFIX,youtube.com,DIRECT 这种奇怪的东西,或者提前遇到了 IP 规则,那么,本地会发起 DNS 请求。但是,如果你在 nameserver 中配置了 https://security.cloudflare-dns.com/dns-query 这样的国外(或其他地区)的 DOH,泄漏检测站点是无法记录到你的真实所在地的。

      显然,这种情况与我们界定的 DNS 泄漏有所冲突。

      广义上讲,访问应该被代理的网站的 DNS 请求必须由落地代理产生,而真正的发送端被隐藏起来。如果你认为只要访问被代理的网站时,本地发起了 DNS 请求就算泄漏的话,这种情况属于 DNS 泄漏,你的配置不正常。

      狭义上讲,如果你觉得尽管本地发起了 DNS 请求,但只要不被正确溯源就可以接受。那么在兼顾延迟和安全的前提下,选择经过特殊设计的境外 DOH / DOQ 是个不错的选择查看已知的免审查公有 DNS

如何避免 DNS 泄漏

触类旁通

TUNNEL Mode

Enhanced Mode

DNSMaping

FakeIP: Clash & Meta

FakeDNS: v2ray & sing-box

L3: Loss but deepin magic

软路由 - 物理超度

一叶知秋

处理 Squad EAC 反外挂验证

解锁流媒体

绕过 ChatGPT 网络审查

天外飞仙

基于 Rust 实现 PBQ QUIC-based 拥塞控制算法

Clash.Meta 参考配置

  1. Meta 官方提供的懒人配置
  2. 本篇博客的附件

What’s more

术语表

  1. clash rules 直连,代理,拒绝

  2. v2fly / sing-box rules 直连(绕过),代理,阻止

  3. DNS

  4. Clash DNS

    1. nameserver-policy
    2. default-nameserver
    3. nameserver
    4. proxy-server-nameserver
    5. fake-ip enhancer
  5. DNS leak

    概念界定:广义、狭义

  6. DOH / DOT / DOQ / ODOH

源码阅读

初始化配置

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
func UnmarshalRawConfig(buf []byte) (*RawConfig, error) {
    // config with default value
    rawCfg := &RawConfig{
        AllowLan:        false,
        BindAddress:     "*",
        IPv6:            true,
        Mode:            T.Rule,
        GeodataMode:     C.GeodataMode,
        GeodataLoader:   "memconservative",
        UnifiedDelay:    false,
        Authentication:  []string{},
        LogLevel:        log.INFO,
        Hosts:           map[string]any{},
        Rule:            []string{},
        Proxy:           []map[string]any{},
        ProxyGroup:      []map[string]any{},
        TCPConcurrent:   false,
        FindProcessMode: P.FindProcessStrict,
        Tun: RawTun{
            Enable:              false,
            Device:              "",
            Stack:               C.TunGvisor,
            DNSHijack:           []string{"0.0.0.0:53"}, // default hijack all dns query
            AutoRoute:           true,
            AutoDetectInterface: true,
            Inet6Address:        []LC.ListenPrefix{LC.ListenPrefix(netip.MustParsePrefix("fdfe:dcba:9876::1/126"))},
        },
        TuicServer: RawTuicServer{
            Enable:                false,
            Token:                 nil,
            Users:                 nil,
            Certificate:           "",
            PrivateKey:            "",
            Listen:                "",
            CongestionController:  "",
            MaxIdleTime:           15000,
            AuthenticationTimeout: 1000,
            ALPN:                  []string{"h3"},
            MaxUdpRelayPacketSize: 1500,
        },
        EBpf: EBpf{
            RedirectToTun: []string{},
            AutoRedir:     []string{},
        },
        IPTables: IPTables{
            Enable:           false,
            InboundInterface: "lo",
            Bypass:           []string{},
        },
        DNS: RawDNS{
            Enable:       false,
            IPv6:         false,
            UseHosts:     true,
            IPv6Timeout:  100,
            EnhancedMode: C.DNSMapping,
            FakeIPRange:  "198.18.0.1/16",
            FallbackFilter: RawFallbackFilter{
                GeoIP:     true,
                GeoIPCode: "CN",
                IPCIDR:    []string{},
                GeoSite:   []string{},
            },
            DefaultNameserver: []string{
                "114.114.114.114",
                "223.5.5.5",
                "8.8.8.8",
                "1.0.0.1",
            },
            NameServer: []string{
                "<https://doh.pub/dns-query>",
                "tls://223.5.5.5:853",
            },
            FakeIPFilter: []string{
                "dns.msftnsci.com",
                "www.msftnsci.com",
                "www.msftconnecttest.com",
            },
        },
        Sniffer: RawSniffer{
            Enable:          false,
            Sniffing:        []string{},
            ForceDomain:     []string{},
            SkipDomain:      []string{},
            Ports:           []string{},
            ForceDnsMapping: true,
            ParsePureIp:     true,
            OverrideDest:    true,
        },
        Profile: Profile{
            StoreSelected: true,
        },
        GeoXUrl: RawGeoXUrl{
            Mmdb:    "<https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/country.mmdb>",
            GeoIp:   "<https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.dat>",
            GeoSite: "<https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geosite.dat>",
        },
    }
    
    if err := yaml.Unmarshal(buf, rawCfg); err != nil {
        return nil, err
    }
    
    return rawCfg, nil
}

DNSMode Alias

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func (e DNSMode) String() string {
    switch e {
        case DNSNormal:
            return "normal"
        case DNSFakeIP:
            return "fake-ip"
        case DNSMapping:
            return "redir-host"
        case DNSHosts:
            return "hosts"
        default:
            return "unknown"
    }
}

type RawDNS struct

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
type RawDNS struct {
    Enable                bool              `yaml:"enable"`
    PreferH3              bool              `yaml:"prefer-h3"`
    IPv6                  bool              `yaml:"ipv6"`
    IPv6Timeout           uint              `yaml:"ipv6-timeout"`
    UseHosts              bool              `yaml:"use-hosts"`
    NameServer            []string          `yaml:"nameserver"`
    Fallback              []string          `yaml:"fallback"`
    FallbackFilter        RawFallbackFilter `yaml:"fallback-filter"`
    Listen                string            `yaml:"listen"`
    EnhancedMode          C.DNSMode         `yaml:"enhanced-mode"`
    FakeIPRange           string            `yaml:"fake-ip-range"`
    FakeIPFilter          []string          `yaml:"fake-ip-filter"`
    DefaultNameserver     []string          `yaml:"default-nameserver"`
    NameServerPolicy      map[string]any    `yaml:"nameserver-policy"`
    ProxyServerNameserver []string          `yaml:"proxy-server-nameserver"`
}

DNS 默认配置

Clash.Meta/config/config.go MetaCubeX/Clash.Meta

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
DNS: RawDNS{
    Enable:       false,
    IPv6:         false,
    UseHosts:     true,
    IPv6Timeout:  100,
    EnhancedMode: C.DNSMapping,
    FakeIPRange:  "198.18.0.1/16",
    FallbackFilter: RawFallbackFilter{
        GeoIP:     true,
        GeoIPCode: "CN",
        IPCIDR:    []string{},
        GeoSite:   []string{},
    },
    DefaultNameserver: []string{
        "114.114.114.114",
        "223.5.5.5",
        "8.8.8.8",
        "1.0.0.1",
    },
    NameServer: []string{
        "<https://doh.pub/dns-query>",
        "tls://223.5.5.5:853",
    },
    FakeIPFilter: []string{
        "dns.msftnsci.com",
        "www.msftnsci.com",
        "www.msftconnecttest.com",
    },
}

type Pool struct

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Pool is an implementation about fake ip generator without storage
type Pool struct {
    gateway netip.Addr
    first   netip.Addr
    last    netip.Addr
    offset  netip.Addr
    cycle   bool
    mux     sync.Mutex
    host    *trie.DomainTrie[struct{}]
    ipnet   *netip.Prefix
    store   store
}

GEOSITE,GEOIP 大小写不敏感

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 将国家代码转成小写后再匹配
func LoadGeoSiteMatcher(countryCode string) {
    // -- skip --
    countryCode = strings.ToLower(countryCode)
    // -- skip --
}
// LAN 特例,必须大写才能匹配规则
func LoadGeoIPMatcher(country string) {
    // -- skip --
    country = strings.ToLower(country)
    // -- skip --
}

// ---
countryCode = strings.ToLower(countryCode) 

换句话说,以下写法是等价的:

1
2
3
4
5
6
rules:
  - GEOSITE,PRIVATE,DIRECT
  - GEOSITE,private,DIRECT
rules:
  - GEOIP,cn,DIRECT
  - GEOIP,CN,DIRECT

new tuic

NewTuic MetaCubeX/Clash.Meta

常见问题汇总

  1. Windows DNS 策略组问题
  2. 使用 clash 一顿操作连不上公司内网

Reference

You will to enjoy grander sight / By climing to a greater height.