透明代理这个东西说起来很容易理解,就是设备感知不到有的流量被代理,却有开了代理的效果。这个东西对于学习境外内容的体验提升很大,因为部分应用并不遵循系统代理设置,或设置很麻烦。避免将代理配置文件存放在电脑上,也可以防止电脑上的应用窃取代理信息。
透明代理本身在中文互联网已经是聊烂了的话题,但使用Nftables实现的透明代理很少,在主路由上实现透明代理的更少。我的网络拓扑大概是:外部网络 - R2S - 无线AP - 其他设备。本文将基于这个拓扑,实现在R2S上使用Nftables实现透明代理。
这个东西作为上篇文章 的一个小节显得有点喧宾夺主了,所以就单独写一篇文章吧。
前置知识
只需要基础的计网知识,以及初步了解Nftables(起码能看懂规则)。
另外丢掉那张老旧的(you know what)Netfilter数据包流向图吧,由于Nftables的链是可以自由添加、自定义优先级的,现在它应该是这样 :
Netfilter 数据包流向图
思路
要达到的效果是这样的:
DHCP服务器将默认DNS服务器指向本机的Telescope DNS,由其负责解析 ,直连DoH
局域网内通信不经过代理工具,包括DHCP等特殊服务
其他来自局域网内的TCP/UDP流量都经过代理工具,由代理工具分流
不代理路由本身流量,暂时没需求
也就是说流量会经过Nftables和代理工具两次分流。对于较为固定、不高于三层的分流规则,交给Nftables效率更高;而对于按站点分流之类的规则,则交给代理工具,更灵活。
对于第一条,局域网内机器发送数据包的路径为:
局域网内客户端发出DNS请求
Nftables prerouting 对目的为局域网内的请求进行直连,不代理
Telescope DNS接收到请求(此处设未缓存)
Telescope DNS向上游发出请求,不带任何meta mark
Nftables postrouting 进行NAT
远程DNS服务器收到请求,返回结果
Nftables prerouting 对来自局域网外的请求直连
Telescope DNS收到上游回复
Telescope DNS将结果返回给客户端
Nftables postrouting 进行NAT
客户端收到回复
对内网的包:
局域网内客户端发出请求
Nftables prerouting 对目的为局域网内的请求进行直连,不代理
进入 forward 链,转发给目标机器
或/同时被本机处理
对分流决定代理的正常TCP/UDP流量:
局域网内客户端发出请求
Nftables prerouting 对于来自局域网、目标为外网的请求,进行 tproxy
代理工具接收并封装请求,并发送到代理服务器,带有meta mark 2
Nftables postrouting 进行NAT
代理服务器处理代理请求,返回结果
Nftables prerouting 对于来自外网的请求,不代理(直接发往代理工具)
代理工具接收并解封装请求,将结果返回给客户端
Nftables postrouting 进行NAT
客户端收到回复
对于决定不分流的正常TCP/UDP流量,其实也和上面相似,只不过代理工具直接将请求发往目标服务器,目标服务器也直接把结果返回给代理工具。
实现
类似于 Xray TProxy 透明代理 之类的本机透明代理方案很多,一般也适用于旁路由。核心的规则其实就两条,也就是在 prerouting 的链上加入(tproxy 仅支持 prerouting):
ip protocol tcp tproxy to 127.0.0.1:12345 meta mark set 1
ip protocol udp tproxy to 127.0.0.1:12345 meta mark set 1
1
2
ip protocol tcp tproxy to 127.0.0.1 : 12345 meta mark set 1
ip protocol udp tproxy to 127.0.0.1 : 12345 meta mark set 1
然而就以上面链接中这篇教程的配置为例,它包含如下三行规则,通过跳过目的为本地IP的代理,同时避免了远程服务器发回的流量被重新代理 和局域网内主机间流量被代理 的问题:
ip daddr $RESERVED_IP return
ip daddr 192.168.0.0/16 tcp dport != 53 return
ip daddr 192.168.0.0/16 udp dport != 53 return
1
2
3
ip daddr $RESERVED_IP return
ip daddr 192.168.0.0 / 16 tcp dport != 53 return
ip daddr 192.168.0.0 / 16 udp dport != 53 return
第二个问题很好理解。而这些规则能解决第一个问题,主要原因在于主路由已经做好了NAT,看起来已经是从远程主机IP发往代理客户端主机IP的流量了,也就是有了目的IP在上述范围内的特征。同样的方法却不能套到主路由上,因为NAT一般在 postrouting 阶段去做,而 tproxy 一般在 prerouting 阶段进行 。此时,网络层的目的IP仍然是本机的外网IP,而非192.168.0.1这样的局域网IP。
然而,出于某些原因,我们并不能在 postrouting 阶段等待NAT完成后再更改目的IP ,也就是说这个阶段已经不能把数据包拐到代理上面了。考虑到 tproxy 的作用阶段,实际上在 prerouting 中同时进行Nftables阶段的分流和 tproxy 更为合适。相应的,为了识别远程服务器发回流量,我们要添加一条规则,也就是源IP不在内网IP段中 ,则不代理。
另外不管是上面的教程还是Linux kernel文档 ,都指出 tproxy 应搭配自定义IP route使用,以让透明代理的包正确地传输到本地。在进行透明代理的同时为数据包附上meta mark 1,这样就可以被以下两条命令定义的路由规则捕获,然后被传输到本地:
ip rule add fwmark 1 lookup 100 # 对于带标记 1 的包,使用编号为 100 的路由表(100 没有什么特殊含义)
ip route add local 0.0.0.0/0 dev lo table 100 # 对于路由表 100,经过 lo 设备发送到 0.0.0.0
1
2
ip rule add fwmark 1 lookup 100 # 对于带标记 1 的包,使用编号为 100 的路由表(100 没有什么特殊含义)
ip route add local 0.0.0.0/0 dev lo table 100 # 对于路由表 100,经过 lo 设备发送到 0.0.0.0
实验表明去掉这两条规则后,无法正确进行透明代理。由于 tproxy 并不会修改数据包本身的内容,所以我猜测该数据包可能会按照默认策略路由,不会进入本地回环为代理工具所接收。
接下来就该配置代理软件了。我个人建议使用Xray-core,需要添加入站:
{
"inbounds": [
{
"tag": "all-in",
"port": 12345,
"listen": "127.0.0.1",
"protocol": "dokodemo-door",
"settings": {
"network": "tcp,udp",
"followRedirect": true
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
]
},
"streamSettings": {
"sockopt": {
"tproxy": "tproxy"
}
}
},
]
}
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
{
"inbounds" : [
{
"tag" : "all-in" ,
"port" : 12345 ,
"listen" : "127.0.0.1" ,
"protocol" : "dokodemo-door" ,
"settings" : {
"network" : "tcp,udp" ,
"followRedirect" : true
},
"sniffing" : {
"enabled" : true ,
"destOverride" : [
"http" ,
"tls"
]
},
"streamSettings" : {
"sockopt" : {
"tproxy" : "tproxy"
}
}
},
]
}
并且建议为每个出站都设定meta mark不为1:
{
"outbounds": [
{
"streamSettings":{
"sockopt": {
"mark": 2
},
},
},
]
}
1
2
3
4
5
6
7
8
9
10
11
{
"outbounds" : [
{
"streamSettings" :{
"sockopt" : {
"mark" : 2
},
},
},
]
}
在添加了上面的规则后,发现连不上新设备了,查找DHCP服务器的日志也并未发现端倪。发现由于DHCP的Discover目标为广播IP,并未被排除代理,所以被代理工具截留。我使用的workaround为添加一条规则,对目标为udp/67端口的数据包不代理,DHCP恢复正常。
宿舍的网络竟然有外界可以直接访问的公网IP(震惊),所以个人还加了 filter 表,用于防止外界白嫖代理。这个看一下下面的配置文件就明白,我不多讲。
配置
如果你希望使用此配置文件,请根据你路由器的接口分配、网段分配、代理工具等情况进行修改。另外也不要忘了添加 ip 路由规则。
#!/usr/sbin/nft -f
flush ruleset
define RESERVED_IP = {
192.168.0.0/24,
0.0.0.0/8,
}
define WANLINK = lan0
define LANLINK = eth0
table ip nat {
chain postrouting {
type nat hook postrouting priority 100; policy accept;
oifname $WANLINK masquerade
}
}
table ip filter {
chain input {
type filter hook input priority 0; policy accept;
# only accept local/LAN traffic to tproxy
ip saddr != $RESERVED_IP tcp dport 12345 drop
}
}
table ip xray {
chain prerouting {
type filter hook prerouting priority mangle; policy accept;
# source not from LAN (e.g. server reply), don't proxy
ip saddr != $RESERVED_IP return
# dest loopback or local traffic, don't proxy
ip daddr $RESERVED_IP return
# allow dhcp traffic (daddr may be 255.255.255.0)
udp dport 67 return
# tproxy traffic
ip protocol {tcp,udp} tproxy to 127.0.0.1:12345 meta mark set 1
}
}
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
#!/usr/sbin/nft -f
flush ruleset
define RESERVED_IP = {
192.168.0.0 / 24 ,
0.0.0.0 / 8 ,
}
define WANLINK = lan0
define LANLINK = eth0
table ip nat {
chain postrouting {
type nat hook postrouting priority 100 ; policy accept ;
oifname $WANLINK masquerade
}
}
table ip filter {
chain input {
type filter hook input priority 0 ; policy accept ;
# only accept local/LAN traffic to tproxy
ip saddr != $RESERVED_IP tcp dport 12345 drop
}
}
table ip xray {
chain prerouting {
type filter hook prerouting priority mangle ; policy accept ;
# source not from LAN (e.g. server reply), don't proxy
ip saddr != $RESERVED_IP return
# dest loopback or local traffic, don't proxy
ip daddr $RESERVED_IP return
# allow dhcp traffic (daddr may be 255.255.255.0)
udp dport 67 return
# tproxy traffic
ip protocol { tcp , udp } tproxy to 127.0.0.1 : 12345 meta mark set 1
}
}
IPv6
本节参考
IPv6透明代理的实现与上面相似,甚至可以不用建新表,而是修改 xray 表即可。
IPv6对应的路径设置如下:
ip -6 rule add fwmark 1 table 106
ip -6 route add local ::/0 dev lo table 106
1
2
ip -6 rule add fwmark 1 table 106
ip -6 route add local ::/0 dev lo table 106
这里我直接将修改后的 xray 表列在下面:
table inet xray {
chain prerouting {
type filter hook prerouting priority filter; policy accept;
ip daddr { 127.0.0.0/8, 224.0.0.0/4, 255.255.255.255 } return
ip6 daddr 2408::/16 return
meta l4proto tcp ip daddr 192.168.0.0/16 return
ip daddr 192.168.0.0/16 udp dport != 53 return
ip6 daddr { ::1, fe80::/10 } return
meta l4proto tcp ip6 daddr fd00::/8 return
ip6 daddr fd00::/8 udp dport != 53 return
# tailscale
udp sport 41641 return
udp dport 41641 return
udp dport 3478 return
udp sport 3478 return
meta mark 2 return
meta l4proto { tcp, udp } meta mark set 1 tproxy ip to 127.0.0.1:12345 accept
meta l4proto { tcp, udp } meta mark set 1 tproxy ip6 to [::1]:12345 accept
}
chain divert {
type filter hook prerouting priority mangle; policy accept;
meta l4proto tcp socket transparent 1 meta mark set 1 accept
}
}
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
table inet xray {
chain prerouting {
type filter hook prerouting priority filter ; policy accept ;
ip daddr { 127.0.0.0 /8, 224.0.0.0/ 4 , 255.255.255.255 } return
ip6 daddr 2408 ::/ 16 return
meta l4proto tcp ip daddr 192.168.0.0 / 16 return
ip daddr 192.168.0.0 / 16 udp dport != 53 return
ip6 daddr { :: 1 , fe80:: / 10 } return
meta l4proto tcp ip6 daddr fd00:: / 8 return
ip6 daddr fd00:: / 8 udp dport != 53 return
# tailscale
udp sport 41641 return
udp dport 41641 return
udp dport 3478 return
udp sport 3478 return
meta mark 2 return
meta l4proto { tcp , udp } meta mark set 1 tproxy ip to 127.0.0.1 : 12345 accept
meta l4proto { tcp , udp } meta mark set 1 tproxy ip6 to [ :: 1 ]: 12345 accept
}
chain divert {
type filter hook prerouting priority mangle ; policy accept ;
meta l4proto tcp socket transparent 1 meta mark set 1 accept
}
}
如果你的代理工具只监听了IPv4地址,那么需要同时监听IPv6 [::1]:12345。
优化
Nftables数据包追踪
在编写Nftables配置的时候,经常可能有疑问,这个数据包到哪里去了。可以使用 nft monitor trace 监视特定数据包的流向和规则判定过程,详细使用方法见 。
米家设备无法连网
如果开启透明代理的情况下,发现米家设备无法联网(或者无法绑定到App),而关闭透明代理又恢复,那么可能是因为米家设备需要连接域名 mijia cloud(是的你没看错,中间一个空格),代理工具嗅探到了这个域名并尝试解析以分流,但其无法正常处理 ,于是走默认。可以通过查看代理工具的日志来验证这个情况,Xray-core和Clash都可能会有这个问题。
解决方法是跳过这个域名的嗅探分流,如Xray-core加上这一段:
{
"inbounds": [
{
"sniffing": {
"domainsExcluded": [
"mijia cloud"
]
}
}
]
}
1
2
3
4
5
6
7
8
9
10
11
{
"inbounds" : [
{
"sniffing" : {
"domainsExcluded" : [
"mijia cloud"
]
}
}
]
}
干出这个事的小米工程师真该拉出去祭天。
境内流量完全直连
上述的配置会令境内外流量均经过代理工具,最直接的问题就是访问速度变慢。但可能也有些其他后果。
曾遇到一个非常玄学的问题,如国服原神“连接超时”,国服星穹铁道错误“1001_1”等。即使设置了正确的分流规则和DNS解析,也可能无法登录。抓包发现有许多和米哈游的TCP连接被reset掉,暂时不知道根本原因。
个人的解决办法是使用Nftables进行国内IP分流,具体可见 这篇文章 。实测使用该措施后,由于不需要回到用户态过代理软件,日常上网也会快一点。不过若依赖于特定代理软件的附加功能,如Xray-core的Full Cone NAT,则对于绕过的流量,也会一并失去这些功能。
此处附一个适用于IPv4和IPv6的转换脚本,将APNIC的数据转换为nftables格式,并输出到标准输出:
import re
# Function to calculate subnet mask from number of IPs
def get_cidr_from_size(size):
import math
return 32 - int(math.log2(size))
def parse_ip_ranges(file_path):
v4_list = []
v6_list = []
# Regular expressions for extracting IPv4 and IPv6 ranges
ipv4_regex = r'apnic\|CN\|ipv4\|([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)\|(\d+)\|'
ipv6_regex = r'apnic\|CN\|ipv6\|([0-9a-fA-F:]+)\|(\d+)\|'
with open(file_path, 'r') as file:
for line in file:
# Check for IPv4
match_v4 = re.search(ipv4_regex, line)
if match_v4:
ip = match_v4.group(1)
size = int(match_v4.group(2))
# Calculate CIDR from the size
cidr = get_cidr_from_size(size)
subnet = f"{ip}/{cidr},"
v4_list.append(subnet)
# Check for IPv6
match_v6 = re.search(ipv6_regex, line)
if match_v6:
ip = match_v6.group(1)
prefix_length = int(match_v6.group(2))
# Construct the subnet
subnet = f"{ip}/{prefix_length},"
v6_list.append(subnet)
return v4_list, v6_list
def generate_nftables_rule(file_path):
v4_list, v6_list = parse_ip_ranges(file_path)
# Format the output in nftables style
v4_rules = "define chnroute_list_v4 = {\n" + "\n ".join(v4_list) + "\n}"
v6_rules = "define chnroute_list_v6 = {\n" + "\n ".join(v6_list) + "\n}"
return v4_rules, v6_rules
if __name__ == "__main__":
file_path = 'delegated-apnic-latest' # Replace with your actual file path
v4_rules, v6_rules = generate_nftables_rule(file_path)
# Print the results
print(v4_rules)
print(v6_rules)
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
import re
# Function to calculate subnet mask from number of IPs
def get_cidr_from_size ( size ):
import math
return 32 - int ( math . log2 ( size ))
def parse_ip_ranges ( file_path ):
v4_list = []
v6_list = []
# Regular expressions for extracting IPv4 and IPv6 ranges
ipv4_regex = r 'apnic\|CN\|ipv4\|([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)\|(\d+)\|'
ipv6_regex = r 'apnic\|CN\|ipv6\|([0-9a-fA-F:]+)\|(\d+)\|'
with open ( file_path , 'r' ) as file :
for line in file :
# Check for IPv4
match_v4 = re . search ( ipv4_regex , line )
if match_v4 :
ip = match_v4 . group ( 1 )
size = int ( match_v4 . group ( 2 ))
# Calculate CIDR from the size
cidr = get_cidr_from_size ( size )
subnet = f " { ip } / { cidr } ,"
v4_list . append ( subnet )
# Check for IPv6
match_v6 = re . search ( ipv6_regex , line )
if match_v6 :
ip = match_v6 . group ( 1 )
prefix_length = int ( match_v6 . group ( 2 ))
# Construct the subnet
subnet = f " { ip } / { prefix_length } ,"
v6_list . append ( subnet )
return v4_list , v6_list
def generate_nftables_rule ( file_path ):
v4_list , v6_list = parse_ip_ranges ( file_path )
# Format the output in nftables style
v4_rules = "define chnroute_list_v4 = { \n " + " \n " . join ( v4_list ) + " \n }"
v6_rules = "define chnroute_list_v6 = { \n " + " \n " . join ( v6_list ) + " \n }"
return v4_rules , v6_rules
if __name__ == "__main__" :
file_path = 'delegated-apnic-latest' # Replace with your actual file path
v4_rules , v6_rules = generate_nftables_rule ( file_path )
# Print the results
print ( v4_rules )
print ( v6_rules )