Featured image of post 基于DNS的分流方案

基于DNS的分流方案

使用小猫和mosdns优雅的实现分流

最近将路由器上的工具换为小猫,使用默认的redir-host模式虽然能用,但总是出现各种各样奇怪的问题,再加上小猫官方已经删除了redir-host模式,于是我便想找到一个基于fake-ip的分流方式。通过在互联网上检索发现,找到了这篇文章——《基于 DNS 的内网透明代理分流方案》。仔细研读下来,完美符合我的需求,然而,我使用的是内存捉襟见肘的硬路由,并没有足够的空间装下AdGuardHome+mosdns+小猫三员大将。于是,我便在他方案的基础上删除了AdGuardHome,优化出了现在的方案。

# 实现效果

和原来的方案一样,我就直接粘贴了。

  1. 基于DNS的流量分流,国内流量绕过小猫核心
  2. 用Fake-IP模式来解决DNS污染的问题,但限制Fake-IP的范围,不需要代理的域名仍返回正常IP
  3. 不用再费心找无污染的DNS服务器,使用运营商提供的DNS也没问题
  4. 因为彻底解决了DNS污染,可以放心缓存DNS请求结果
  5. 完美兼容IPv6。国内流量可正常使用IPv6服务。只要代理有IPv6出口,那国外也可正常使用。
  6. 兼容BT/PT应用,无需特殊配置也不会消耗代理流量
  7. 可以松切换内网设备是否走代理

# 遇到问题

在按照原来的方案构建并删去AdGuardHome后,可以实现大部分的功能。然而在我的使用中,却遇到了一个关键的问题:无法区分流量来源——由于mosdns是作为Dnsmasq的上游来使用的,所以无法识别请求的客户端,故无法实现是否走代理。

为此,我尝试将mosdns代替dnsmasq来监听53端口,结果又遇到了一个新的问题:所有的请求都返回fake-ip——由于dns存在主机名解析功能,导致请求被错误的发往小猫的DNS端口,从而返回了fake-ip。

# 方案设计

为了解决这两个问题,我重新设计了DNS解析的流程,从而得到了现在的这一套方案。

# DNS分流

graph TB
client[DNS 请求] -- 防火墙劫持 -->  mosdns
	mosdns -- 需要代理的设备 --> query[按规则进行DNS查询]
	mosdns -- 不需要代理的设备 --> dnsmasq
    query -- 国内域名 --> dnsmasq
    query -- 国外域名 --> drop_ipv6[\忽略 IPv6 请求/]
    query -- 其它 --> fallback
    fallback -- 国内IP --> dnsmasq
    fallback -- 国外IP --> drop_ipv6

    dnsmasq --> sp_dns[真实IP]
    query -- 域名白名单 --> dnsmasq
    query -- 域名灰名单 --> drop_ipv6
    drop_ipv6 --> fake[Fake IP]
  

经过DNS分流后,所有需要代理的域名都分配到了Fake IP,无需代理的域名都是由运营商DNS返回的最优结果。小猫的DNS在Fake IP模式下可以无需请求网络直接返回结果,所以整体的DNS响应速度非常快。在处理Fake IP的流量时,小猫会把hostname发送到远端进行DNS解析,也就自然不存在DNS污染的问题了。

# 修复地址解析错误

由于dns存在主机名解析功能,会导致请求错误的转发。正所谓“头痛医头,脚痛医脚”,最简单和无脑的方法就是直接关闭这个功能:

网络-DHCP/DNS-高级设置里,将本地域名里的值删除。(未测试,不确定🤔)

但是我们都使用了mosdns了,为什么不好好的利用它那强大的规则呢。所以,我们可以将有关主机名解析的流量全部转发到dnsmasq,这样就不会出问题了。

# 区分流量来源

一开始,mosdns是作为dnsmasq的上游的,所以所有的请求来源都是本地,这就导致了我们无法控制mosdns,让其为不需要代理的设备分配真实的IP。所以,我们将所有dns请求通过防火墙劫持到mosdns上,就能知道设备的来源的ip了。关于防火墙的写法,我参考了luci-app-adguardhome的开源代码

但是这样还有一个问题,由于我们配置了ipv6,所以请求的客户端地址很大可能也是ipv6的。由于ipv6地址支持无状态分配,这就导致请求的地址会一直变动,这也导致无法为特定客户端实现分流。这里我选择了最简单和无脑的方法——直接关闭ipv6的DNS服务。通过查询Openwrt的官方文档,我们发现官方提供了关闭ipv6 DNS服务的方法。这样就可以让客户端始终通过ipv4来请求了。确保了这一点,我们的防火墙也只用劫持ipv4的流量了。

# 具体配置

上面都在讲思路,终于轮到具体的配置了。我们现在只需要小猫和luci-app-mosdns这两个包就好了。

# 小猫

  • 插件设置 - 模式设置 - 运行模式: 切换到 Fake-IP(增强)模式
  • 插件设置 - DNS 设置 - 本地 DNS 劫持 选择 禁用
  • 插件设置 - 流量控制 - 绕过中国大陆 IP 取消勾选
  • 插件设置 - 流量控制 - 仅允许内网 开启
  • 插件设置 - IPv6 设置 这页的选项全都关闭就行了
  • 覆写设置 - 常规设置 这里都不用改,只需要记住 DNS 监听,后面配置 mosdns 要用
  • 覆写设置 - DNS 设置 - 自定义上游 DNS 服务器 勾选
  • 覆写设置 - DNS 设置 - 追加上游 DNS 勾选
  • 覆写设置 - DNS 设置 - 追加默认 DNS 勾选
  • 覆写设置 - DNS 设置 - Fake-IP 持久化 勾选
  • 覆写设置 - DNS 设置 页面下方 NameServerFallBackDefault-NameServer 里的 DNS 服务器全都取消勾选,我们只用运营商提供的 DNS 服务器就够了,一般运营商 DNS 都是最快的,也是 CDN 最优化的。
  • 插件设置 - 开发者选项 里,我们自定义一下防火墙规则,增加如下这些行。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
LOG_OUT "limit route to only fake ips with proxy port 7892"

/etc/mosdns/genipset.sh

iptables -t nat -D openclash -p tcp -j REDIRECT --to-ports 7892
iptables -t nat -A openclash -m set --match-set telegram dst -p tcp -j REDIRECT --to-ports 7892

LOG_OUT "restart mosdns"
/etc/init.d/mosdns restart

LOG_OUT "将53端口重定向到mosdns"
iptables -t nat -D PREROUTING -p udp -d 192.168.6.1 --dport 53 -j REDIRECT --to-ports 5335
iptables -t nat -A PREROUTING -p udp -d 192.168.6.1 --dport 53 -j REDIRECT --to-ports 5335
iptables -t nat -D PREROUTING -p tcp -d 192.168.6.1 --dport 53 -j REDIRECT --to-ports 5335
iptables -t nat -A PREROUTING -p tcp -d 192.168.6.1 --dport 53 -j REDIRECT --to-ports 5335

这里有几个注意点,都是我踩过的坑😢:

  • 可能是我内核版本太旧了,这里使用任何变量都是空值,所以我把所有地址都写死了,记得根据自己的路由器地址进行修改
  • 不要忘记劫持TCP流量!我之前仅仅劫持了UDP流量导致浏览器一直请求到真实ip而无法访问。

2024.09.26更新 使用nftables的高版本openwrt需要使用下面的脚本:

 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
LOG_OUT "Start Add Custom Firewall Rules..."

FW4=$(command -v fw4)
en_mode=$(uci -q get openclash.config.en_mode)
proxy_port=$(uci -q get openclash.config.proxy_port)

if [ "$en_mode" == "fake-ip" ]; then
  LOG_OUT "limit route to only fake ips with proxy port $proxy_port"
  
  /root/genipset.sh
  
  if [ -n "$FW4" ]; then
    handle=$(nft -a list chain inet fw4 openclash | grep 'ip protocol tcp counter' | awk '{print $NF}')
    LOG_OUT "deleting nft rule handle $handle"
    nft delete rule inet fw4 openclash handle $handle
    nft add rule inet fw4 openclash ip protocol tcp ip daddr @telegram counter redirect to $proxy_port
  else
    iptables -t nat -D openclash -p tcp -j REDIRECT --to-ports $proxy_port
    iptables -t nat -A openclash -m set --match-set telegram dst -p tcp -j REDIRECT --to-ports $proxy_port
  fi
  
  LOG_OUT "restart mosdns"
  /etc/init.d/mosdns restart
  
  LOG_OUT "将53端口重定向到mosdns"

  if [ -n "$FW4" ]; then
    ! nft --check list table inet mosdns > "/dev/null" 2>&1 || nft delete table inet mosdns
    nft add table inet mosdns
    nft add chain inet mosdns prerouting "{ type nat hook prerouting priority -110; policy accept; }"
    nft add rule inet mosdns prerouting "meta nfproto { ipv4 } udp dport 53 ip daddr 192.168.6.1 counter redirect to :5335 comment \"MOSDNS HIJACK\""
    nft add rule inet mosdns prerouting "meta nfproto { ipv4 } tcp dport 53 ip daddr 192.168.6.1 counter redirect to :5335 comment \"MOSDNS HIJACK\""
  else
    iptables -t nat -D PREROUTING -p udp -d 192.168.6.1 --dport 53 -j REDIRECT --to-ports 5335
    iptables -t nat -A PREROUTING -p udp -d 192.168.6.1 --dport 53 -j REDIRECT --to-ports 5335
    iptables -t nat -D PREROUTING -p tcp -d 192.168.6.1 --dport 53 -j REDIRECT --to-ports 5335
    iptables -t nat -A PREROUTING -p tcp -d 192.168.6.1 --dport 53 -j REDIRECT --to-ports 5335  
  fi
fi

genipset.sh 的代码如下,这是针对某些应用不通过DNS直接用ip访问,要将这部分流量劫持到小猫。这个文件放到路由器上后,记得要执行 chmod a+x /etc/mosdns/genipset.sh 给它赋予可执行权限。

 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
#!/bin/bash

tag="telegram"
filename="/etc/mosdns/data/telegram-cidr.txt"
FW4=$(command -v fw4)

if test -f "$filename"; then
    if [ -n "$FW4" ]; then
        nft add set inet fw4 "$tag" { type ipv4_addr\; flags interval\;  auto-merge\; }
        nft add set inet fw4 "${tag}6" { type ipv6_addr\; flags interval\;  auto-merge\; }
        nft flush set inet fw4 "$tag"
        nft flush set inet fw4 "${tag}6"
    fi
    ipset create "$tag" hash:net -!
    ipset create "${tag}6" hash:net family inet6 -!
    ipset flush "$tag"
    ipset flush "${tag}6"
    while read p; do
        if ! grep -q ":" <<< "$p"; then
            if [ -n "$FW4" ]; then
                nft add element inet fw4 "$tag" { "$p" }
            fi
            ipset add "$tag" "$p"
        else
            if [ -n "$FW4" ]; then
                nft add element inet fw4 "${tag}6" { "$p" }
            fi
            ipset add "${tag}6" "$p"
        fi
    done <"$filename"
else
    echo "$filename missing."
fi

# Mosdns

选自定义配置文件,关闭DNS 转发的勾,然后我就直接贴配置了

  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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# Powered by luoboQAQ
# 适配Clash的fake-ip模式
# 24-08-29:让灰名单优先生效,避免被geosite_cn抢答
log:
  level: info
  file: "/tmp/log/mosdns.log"

# API 入口设置
api:
  http: "0.0.0.0:9091"

plugins:
  # 国内域名
  - tag: geosite_cn
    type: domain_set
    args:
      files:
        - "/etc/mosdns/data/direct-list.txt"
        - "/etc/mosdns/rule/whitelist.txt"

  # 国内 IP
  - tag: geoip_cn
    type: ip_set
    args:
      files:
        - "/etc/mosdns/data/CN-ip-cidr.txt"

  # 国外域名
  - tag: geosite_no_cn
    type: domain_set
    args:
      files:
        - "/etc/mosdns/data/proxy-list.txt"

  # 灰名单域名,强制远程解析
  - tag: greylist
    type: domain_set
    args:
      files:
        - "/etc/mosdns/rule/greylist.txt"    

  # 黑名单域名:屏蔽 DNS 解析
  - tag: blocklist
    type: domain_set
    args:
      files:
        - "/etc/mosdns/rule/blocklist.txt"

  # ddns域名
  - tag: ddnslist
    type: domain_set
    args:
      files:
        - "/etc/mosdns/rule/ddnslist.txt"

  # 自定义 Hosts 重写
  - tag: hosts
    type: hosts
    args:
      files:
        - "/etc/mosdns/rule/hosts.txt"

  # 广告域名
  - tag: adlist
    type: domain_set
    args:
      files:
        - "/etc/mosdns/data/ad-domains.txt"

  # PTR黑名单
  - tag: local_ptr
    type: domain_set
    args:
      files:
        - "/etc/mosdns/rule/local-ptr.txt"

  # 缓存插件
  - tag: cache
    type: cache
    args:
      size: 10240
      lazy_cache_ttl: 86400
      # 将缓存保存在磁盘
      # dump_file: /tmp/mosdns/cache.dump

  # 国内解析
  - tag: local_sequence
    type: sequence
    args:
      - exec: forward 127.0.0.1

  # 国外解析
  - tag: remote_sequence
    type: sequence
    args:
      - matches:
        - qtype 28
        exec: reject 0
      - exec: forward 127.127.127.127:7874
      - exec: ttl 1800-0

  # 有响应终止返回
  - tag: has_resp_sequence
    type: sequence
    args:
      - matches: qname $ddnslist
        exec: ttl 5-5
      - matches: has_resp
        exec: accept

  # fallback 用本地服务器 sequence
  # 返回非国内 ip 则 drop_resp
  - tag: fallback_local
    type: sequence
    args:
      - exec: $local_sequence
      - matches: "!resp_ip $geoip_cn"
        exec: drop_resp

  # fallback 用远程服务器 sequence
  - tag: fallback
    type: fallback
    args:
      primary: fallback_local
      secondary: remote_sequence
      threshold: 500
      always_standby: true

  # 主要的运行逻辑插件
  # sequence 插件中调用的插件 tag 必须在 sequence 前定义,
  # 否则 sequence 找不到对应插件。
  - tag: main_sequence
    type: sequence
    args:
      # 不走代理的ip
      #- matches: client_ip 192.168.6.144
       # exec: forward 127.0.0.1
      #- exec: jump has_resp_sequence

      # hosts
      - exec: $hosts
      - exec: jump has_resp_sequence

      - matches:
        - "!qname $ddnslist"
        - "!qname $blocklist"
        - "!qname $adlist"
        - "!qname $local_ptr"
        exec: $cache
      - exec: jump has_resp_sequence

      # 查询拒绝域名
      - matches: qname $blocklist
        exec: reject 3
      - matches: qname $adlist
        exec: reject 3
      - matches: qtype 65
        exec: reject 3
      - matches:
        - qtype 12
        - qname $local_ptr
        exec: reject 3

      # handle local ptr
      - matches:
        - qtype 12
        exec: forward 127.0.0.1
      - exec: jump has_resp_sequence

      # handle lan query
      - matches:
        - qname regexp:lan regexp:local regexp:arpa
        exec: forward 127.0.0.1
      - exec: jump has_resp_sequence

      - matches:
        - qname $ddnslist
        exec: $local_sequence
      - exec: jump has_resp_sequence

      # 灰名单在geosite_cn之前查询
      - matches:
        - qname $greylist
        exec: $remote_sequence
      - exec: jump has_resp_sequence

      - matches:
        - qname $geosite_cn
        exec: $local_sequence
      - exec: jump has_resp_sequence

      - matches:
        - qname $geosite_no_cn
        exec: $remote_sequence
      - exec: jump has_resp_sequence

      - exec: $fallback

  - tag: udp_server
    type: udp_server
    args:
      entry: main_sequence
      listen: ":5335"

  - tag: tcp_server
    type: tcp_server
    args:
      entry: main_sequence
      listen: ":5335"

在这个配置中,我使用了以下项目提供的域名/IP文件:

为了方便期间,我们可以使用脚本一键更新它们:

1
2
3
4
5
6
7
#!/bin/sh
curl -o "/etc/mosdns/data/direct-list.txt" "https://cdn.jsdelivr.net/gh/Loyalsoldier/v2ray-rules-dat@release/direct-list.txt"
curl -o "/etc/mosdns/data/proxy-list.txt" "https://cdn.jsdelivr.net/gh/Loyalsoldier/v2ray-rules-dat@release/proxy-list.txt"
curl -o "/etc/mosdns/data/CN-ip-cidr.txt" "https://cdn.jsdelivr.net/gh/Hackl0us/GeoIP2-CN@release/CN-ip-cidr.txt"
curl -o "/etc/mosdns/data/ad-domains.txt" "https://anti-ad.net/domains.txt"
curl --resolve core.telegram.org:443:`nslookup -port=5335 core.telegram.org 192.168.6.1 | grep 198 | cut -d ":" -f2 | tr -d " "` -o "/etc/mosdns/data/telegram-cidr.txt" "https://core.telegram.org/resources/cidr.txt"
/etc/init.d/mosdns restart

由于某个地址需要小猫才能访问,同时我们没有劫持路由器的dns查询,所以需要先手动查一下ip再下载。

# 后记

这也是一个大工程了,前前后后也花了好多天,不过做完后使用起来还是非常丝滑和顺畅的,最重要的是自己折腾出来这是在是太酷了🤓。这里还是要感谢方案的原作者和开源项目作者,我从中学到了许多🙏。

# 后续更新

# 2024.08.29

在使用时,会发现Google Play商店不能正常下载或更新应用。在《大陆用户 Google Play 商店无法下载或更新应用的原因分析与解决办法》这篇文章中,作者指出了这是由于国内版商店的api请求链接和下载链接分别通过直连和代理导致的问题,解决方案就是让请求都从代理出去。

具体而言,我们需要将下面这几个域名添加到代理规则中

1
2
3
domain:googleapis.cn
domain:googleapis.com
domain:xn--ngstr-lra8j.com

Mosdns

  • 规则列表-灰名单中添加上面的域名

小猫

  • 覆写设置-规则设置-自定义规则中添加下面的分流规则
1
2
3
4
5
rules:
  # Google Play
  - DOMAIN-SUFFIX,googleapis.cn,🚀 节点选择
  - DOMAIN-SUFFIX,googleapis.com,🚀 节点选择
  - DOMAIN-SUFFIX,xn--ngstr-lra8j.com,🚀 节点选择

这样就可以正常的使用Play商店了

# 2024.09.26

将路由器刷到了最新的immortalWrt,防火墙从iptables升级到了nftables,所以原来的命令不能用了,需要使用新的命令。