探索:适用IPv6、Fullcone NAT的旁路由透明代理方案

最近由于考试周临近,所以博客这边都没怎么更新,这回逮到机会赶紧更一篇。我其实一直有个需求,就是想在学校也能无缝接入家里的网络,访问NAS之类的设备,因此我一直想设置一个透明代理。于是最近断断续续研究了几天,总算是摸索出了一个让自己相对满意的透明代理方案,因此就抽空写了篇博客,权当记录。事先说明:这篇博客仅仅描述了一个透明代理方案,并包含任何代理服务器搭建的内容。方案的大致结构如下图,具体细节和配置我会在后文中详叙。

起因

对我而言,透明代理最重要的利好就是局域网设备接入和CLI程序。原先对于CLI程序我采用的是proxychain,也就是hook的方法。但是这种方法没办法针对自己实现请求的go程序,而更底层的graftcpDNS上游的处理上也存在问题,因此我最后选择了使用透明代理进行解决。我之前使用的是新白话文TPROXY配置,它能解决我几乎所有网络方面的痛点。

不过这个方案(主要是v2ray)还是有若干问题。首先就是配置的切换非常复杂,需要重启v2ray进程才能做到。其次就是没法做到Fullcone NAT,这是v2ray本身机能所限。后来我更换了clash,并保留了v2ray作为透明代理的前置代理。clash提供的RESTful API确实很好的解决了我关于配置切换的问题,但是我发现仍然无法做到Fullcone。在后续的调查中我发现这不仅仅是vmess协议本身的限制,v2ray的行为也注定了靠它没法做到Fullcone。而且仅使用v2ray这样复杂的程序用来做clash的前置也是我无法接受的,因此我才打算探索新的透明代理方案。

要求

Fullcone NAT是必须的。其次就是IPv6的支持,不过这个比较虚无,因为想要给局域网设备设置v6网关是一件很复杂的事情。最后就是性能,由于我的目标是将代理程序部署在旁路由(树莓派)上,因此代理程序的性能要好、占用也不能太大。此外就是要尽可能减少数据包路由的次数,尽量把路由工作放在内核空间(netfilter),降低用户空间切换的开销。

至于为什么不在主路由上部署,原因很简单:主路由性能差。而且设置了旁路由代理就可以通过主路由设置DHCP来控制设备是否启用代理。此外,还有可以部署在我笔记本的Manjaro以供便携使用的优势。

后端代理

后端代理采用clash。虽然v2ray在配置上更加灵活,但是clash在运行状态时更加灵活。RESTful API对我来说是更加重要的,因为借由它就可以使用诸如yacd等WebAPP快速的在配置之间进行切换。

中端代理

中端代理我使用了一个小巧的工具ipt2socks。通过这个工具可以从iptables接受TPROXY流量,并转至clash的Socks入口。

了解clash的朋友可能知道,实际上clash本身提供了TUN功能用于处理iptables来的流量,那为什么还是选择了ipt2socksTPROXY呢?的确,TUN对iptables配置的影响不大,而且它的兼容性实际上高于TPROXY(部分发行版不自带),最重要的是,它还节省了一次将数据包包装Socks协议的过程。

对于这个,我的理由是解耦。不谈clash的实现是否稳定,可以确定的是,几乎没有什么代理软件是不支持Socks协议的,而支持TUN的实际上凤毛麟角。此外,使用Socks还意味着支持诸如MITMProxy此类使用Socks接口的网络应用。而至于性能,在最终的配置下,大多数请求实际上都不会经由这个Socks接口。加之ipt2socks的实现相当纯粹、轻量化(编译后100K不到),因此这一点的性能开销完全是值得的。

ipt2socks的配置简洁到根本没有配置,所有配置都通过命令行参数来完成。可以使用systemd作为守护进程运行,配置如下

[Unit]
Description=utility for converting iptables(redirect/tproxy) to socks5
After=network.target

[Service]
User=nobody
EnvironmentFile=/etc/ipt2socks/ipt2socks.conf
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ExecStart=/usr/bin/ipt2socks -s $server_addr -p $server_port -l $listen_port -j $thread_nums $extra_args
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

这里手工强行加入了配置文件/etc/ipt2socks/ipt2socks.conf,如果你怀念命令行参数的简洁,也可以直接修改ExecStart。配置文件格式如下

# ipt2socks configure file
#
# detailed helps could be found at: https://github.com/zfl9/ipt2socks

# Socks5 server ip
server_addr=127.0.0.1

# Socks5 server port
server_port=1080

# Listen port number
listen_port=60080

# Number of the worker threads
thread_nums=1

# Extra arguments
extra_args=

相关配置和编译流程我已经添加至AUR。Archlinux用户可以直接使用yay之类的程序进行安装。

DNS

我最终的选择是overture

说到特色DNS解析,大多数人大概第一时间就会想到chinadns。的确,chinadns是一个相当完善可靠的程序,但是chinadns也显然不太适合直接作为本地DNS服务器——它没有良好的缓存,并且也不支持复杂的路由规则。所以通常的做法是在前面套一个dnsmasq做缓存与分流,然后把chinadns作为上游。但是dnsmasq本身并不支持代理访问,因此你还需要在iptables层面对dnsmasqchinadns的请求进行分流。这还没完,如果你的后端代理不支持UDP,你还需要把DNS请求的UDP转成TCP请求(dns2tcp工具)。所以最后,你得到了《世 界 名 画》chinadns+dnsmasq+dns2tcp。暂且不论来回进出iptables的次数已经远远超过《半条命》的作品数,光是这个复杂配置我就觉得有够傻的。

此外另一个可能的选择就是clash的內建DNS。而且clash还有fake-ip扩展以减少本地DNS解析的需要。但是问题有二,一个和之前不选择TUN的理由一致;另一个就是其他方案实际上也可以做到接近的效果,而使用fake-ip是要以缺少DNS缓存和可能得到错误的解析内容为代价的。

所以我找到了overture,它支持IPv6、可以方便的替换DNS的Upstream、支持通过Socks代理请求、支持EDNS、有相对完善的Dispatcher,可以说基本满足了我所有的要求。而且它还额外支持RESTful API(虽然目前只能检查cache),给进一步的配置管理带来了可能。

配置参考官方配置就行,AUR软件包的默认配置也OK。就是注意需要将WhenPrimaryDNSAnswerNoneUse改成AlternativeDNS

路由分流

集齐了所有碎片,那下一步就该把他们缝合在一起了。缝合用的道具当然就是iptables了(IPv6就是ipt6ables,配置几乎完全一致)。

分流的策略很简单,就是DNS交给overture,私有地址和目标IP段直连,剩下的交给ipt2socks。不过为了实现目标IP段直连,还需要设定相关规则集(因为规则可能超级多,以大陆IP段为例,都用iptables的效果还是很恐怖的),因此先介绍ipset相关的配置。

ipset

iptablesset模块可以实现按规则集路由,而规则集的添加就是通过ipset完成的。在apnic.net可以查询到分配给中国大陆的IP地址,因此解析下就可以添加到规则集了。脚本如下

# 下载并解析 route
wget --no-check-certificate -O- 'http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest' | grep CN > tmp_ips
cat tmp_ips | grep ipv4 | awk -F\| '{ printf("%s/%d\n", $4, 32-log($5)/log(2)) }' > chnroute.set
cat tmp_ips | grep ipv6 | awk -F\| '{ printf("%s/%d\n", $4, 32-log($5)/log(2)) }' > chnroute6.set
rm -rf tmp_ips
# 导入 ipset 表
sudo ipset -X chnroute &>/dev/null
sudo ipset -X chnroute6 &>/dev/null
sudo ipset create chnroute hash:net family inet
sudo ipset create chnroute6 hash:net family inet6
cat chnroute.set | sudo xargs -I ip ipset add chnroute ip
cat chnroute6.set | sudo xargs -I ip ipset add chnroute6 ip

运行后就可以得到IPv4和IPv6适用的规则集了(chnroutechnroute6)。

iptables

这回是真的开始缝合了。总体的思路还是和新白话文的配置一样,把OUTPUT链的包路由至PREROUTING链,之后再用TPROXY模块进行下一步转发。至于为什么要绕这么一个大圈就和TPROXY本身的实现有关了,可以参考 @某昨 的TProxy探秘

因此规则大概可以分为三个部分:策略路由、PREROUTING链、OUTPUT链。综合如下:

# fwmark 匹配的包进入本地环回
ip -4 rule add fwmark $lo_fwmark table 100
ip -4 route add local default dev lo table 100

########## PREROUTING 链配置 ##########

iptables -t mangle -N TRANS_PREROUTING
iptables -t mangle -A TRANS_PREROUTING -i lo -m mark ! --mark $lo_fwmark -j RETURN
# 规则路由
iptables -t mangle -A TRANS_PREROUTING -p tcp -m addrtype ! --src-type LOCAL ! --dst-type LOCAL -j TRANS_RULE
iptables -t mangle -A TRANS_PREROUTING -p udp -m addrtype ! --src-type LOCAL ! --dst-type LOCAL -j TRANS_RULE
# TPROXY 路由
iptables -t mangle -A TRANS_PREROUTING -p tcp -m mark --mark $lo_fwmark -j TPROXY --on-port $tproxy_port --on-ip $loopback_addr --tproxy-mark $tproxy_mark
iptables -t mangle -A TRANS_PREROUTING -p udp -m mark --mark $lo_fwmark -j TPROXY --on-port $tproxy_port --on-ip $loopback_addr --tproxy-mark $tproxy_mark
# 应用规则
iptables -t mangle -A PREROUTING -j TRANS_PREROUTING

########## OUTPUT 链配置 ##########

iptables -t mangle -N TRANS_OUTPUT
# 直连 @clash
iptables -t mangle -A TRANS_OUTPUT -j RETURN -m owner --uid-owner $direct_user
iptables -t mangle -A TRANS_OUTPUT -j RETURN -m mark --mark 0xff # (兼容配置) 直连 SO_MARK0xff 的流量
# 规则路由
iptables -t mangle -A TRANS_OUTPUT -p tcp -m addrtype --src-type LOCAL ! --dst-type LOCAL -j TRANS_RULE
iptables -t mangle -A TRANS_OUTPUT -p udp -m addrtype --src-type LOCAL ! --dst-type LOCAL -j TRANS_RULE
# 应用规则
iptables -t mangle -A OUTPUT -j TRANS_OUTPUT

这里注意,由于要对clashoverture的流量直连,因此我选择使用owner扩展,将用户clash的流量全部直连处理。之后将clashoverture进程运行在用户clash即可。此外就是由于两个链的路由规则是公共的(对于PREROUTING链也可以用fwmark来路由),因此独立出了TRANS_RULE用来处理公共部分的路由(主要是标记fwmark)。

########## 代理规则配置 ##########

iptables -t mangle -N TRANS_RULE
iptables -t mangle -A TRANS_RULE -j CONNMARK --restore-mark
iptables -t mangle -A TRANS_RULE -m mark --mark $lo_fwmark -j RETURN # 避免回环
# 私有地址
for addr in "${privaddr_array[@]}"; do
    iptables -t mangle -A TRANS_RULE -d $addr -j RETURN
done
# ipset 路由
iptables -t mangle -A TRANS_RULE -m set --match-set $chnroute_name dst -j RETURN
# TCP/UDP 重路由 PREROUTING
iptables -t mangle -A TRANS_RULE -p tcp --syn -j MARK --set-mark $lo_fwmark
iptables -t mangle -A TRANS_RULE -p udp -m conntrack --ctstate NEW -j MARK --set-mark $lo_fwmark
iptables -t mangle -A TRANS_RULE -j CONNMARK --save-mark

规则很简单,基本就是不对匹配私有地址和规则集chnroute的数据包进行标记。并且使用CONNMARK对整个连接的数据包进行标记,减少匹配次数。此外,由于OUTPUT链的数据包还会被路由回PREROUTING链,导致第二次匹配TRANS_RULE,因此遇到有fwmark的包就不必匹配了(没有fwmark的包也不可能二次匹配)。

然后就是DNS流量的拦截。由于我需要对网络中所有DNS流量(UDP53)都进行拦截(无论请求哪个地址,这样就不用再手动改DNS配置了),因此不可避免的需要一次DNAT来将流量转发至overture,所以我们还需要创建nat表的转发规则。但是由于nat表的位置靠后,因此需要在匹配TRANS_RULE(位于mangle表)之前先RETURN所有的DNS流量,这样流量才能进入nat表的转发规则。

# 局域网 DNS 路由
iptables -t mangle -A TRANS_PREROUTING -p udp -m addrtype ! --src-type LOCAL -m udp --dport 53 -j RETURN
iptables -t nat -A TRANS_PREROUTING -p udp -m addrtype ! --src-type LOCAL -m udp --dport 53 -j REDIRECT --to-ports $dns_port
# 这之后是 PREROUTING 链的 TRANS_RULE

# ...

# 本地 DNS 路由
iptables -t mangle -A TRANS_OUTPUT -p udp -m udp --dport 53 -j RETURN
for addr in "${dns_direct_array[@]}"; do
    iptables -t nat -A TRANS_OUTPUT -d $addr -p udp -m udp --dport 53 -j RETURN
done
iptables -t nat -A TRANS_OUTPUT -p udp -m udp --dport 53 -j DNAT --to-destination $local_dns
# 这之后是 OUTPUT 链的 TRANS_RULE

这里还有个坑,就是owner扩展不能很好的识别UDP流量的发送者。因此还需要对直连的DNS服务器单独增加匹配规则(这点我很不满意!但是也没办法……)。不过还好只需要加在OUTPUT链,因为局域网设备就不必直连了。

至于效果么……BOOM忽略那感人的网速,其实刚刚试了下可以到40Mbps左右但是懒得更新图了(逃)

Sum up

最终编写得到了三个脚本:

  • transparent_proxy.sh:透明代理规则设置,需要开机运行
  • import_chnroute.sh:下载并配置chnroute规则,至少需要运行一次,并且规则集文件要和transparent_proxy.sh同目录(当然你也可以修改配置)
  • flush_iptables.sh:清理所有增加的规则(除了ipset

这些代码都可以在我的GitHub找到。编写的时候,我大量参考了ss-tproxy项目的相关代码,非常感谢这个repo。

要部署这个配置,除了这三个shell,你还需要安装ipt2socksoverture(AUR都有对应包:ipt2socksoverture)。此外,还需要一个支持Socks协议的代理(我用的是clash,当然其他可以)。按照文中配置完后,修改transparent_proxy.sh的开头为你配置的相关内容即可。

缺陷

令人遗憾的是,这份配置还是有不完美之处的。不过好在都不是什么大问题,也可以曲线救国。

  1. 对于域名形式的代理服务器,必须给代理程序配置DNS。由于在代理程序启动时需要解析代理服务器的真实IP,因此需要请求overture。这原本没有什么问题,但是为了性能通常会开启AlternativeDNSConcurrent。而此时overture会请求clash以访问备用DNS,但是clash还没启动。其实本来也没有问题,但是错就错在overture在连接不上clash的时候竟然会崩溃!然后clash因为解析不到真实IP所以也跟着一起崩溃,然后overture崩,overture崩完clash崩……解决方案有两个,一个是谨慎的调节启动顺序——iptables规则要在clash解析完毕后请求;另一个就是配置clash本身的DNS,让请求不走overture。前者有点麻烦,而后者实际上是又增加一套和overture处理不同的DNS配置。不过好在普通流量到了clash都已经完成DNS解析,除了直连clash的Socks否则不会用到內建DNS,所以我选择了后者。本质是overture的问题,所以如果它修就可以解决。
  2. 本机直连DNS。之前说DNS配置时提到,必须对本机的直连DNS设置直连规则。而这就导致本机无法拦截对直连DNS服务器的DNS请求。解决方案很简单,就是本机DNS别设置成直连DNS那几个服务器就行。
  3. overture不支持UDP via Socks。这个倒也无所谓,TCP查询就行,对性能的影响可以忽略。

兄啊,怎么都是DNS的问题啊

Reference

  1. [v1.0] Tun+MITMProxy 初探(https://blog.yesterday17.cn/post/transparent-proxy-with-mitmproxy/
  2. zfl9/ss-tproxy(https://github.com/zfl9/ss-tproxy/