Tun2socks 的配置与基于iptables + ipset的国内流量分流

最近回国了就惦记起科学上网的事情了。很多科学上网工具的inbounds都是SOCKS 5协议,SOCKS 5很常用也很好用,但是因为在传输层所以需要应用的配合,这样一来全局代理就比较困难。目前比较成熟的解决方案是tun2socks。Tun2socks有很多实现,原理都是创建一个虚拟网卡(TUN device),然后从发往这个网卡的IP包中辨识出TCP和UDP的流量并转发给代理。为此,tun2socks也需要操作系统的配合。我们需要通过修改路由表把tun2socks创建的而TUN设置为默认网关。这是使用tun2socks的难点,而对于这一步,很多教程都没有说清楚,大部分人也都是使用Clash等可以自动配置tun2socks的应用。我作为一个Ubuntu用户,使用的代理协议又比较特殊,导致Clash不够用,配置起最原始的tun2socks真是踩坑不断。幸好折腾了一个礼拜总算是折腾出几个自动化的脚本,用这篇文章记录一下自己的心得。

看懂Linux的路由表和路由规则

Linux的路由表可以使用ip route show命令查看,例如:

default via 192.168.100.1 dev wlp4s0 proto dhcp metric 600
169.254.0.0/16 dev wlp4s0 scope link metric 1000 
172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1 linkdown 
192.168.100.0/24 dev wlp4s0 proto kernel scope link src 192.168.100.13 metric 600 
198.18.0.0/15 dev tun0 proto kernel scope link src 198.18.0.1

上面的五行信息量不少,怎么解读?

首先看第一列,操作系统会把一个IP包的目标地址和路由表第一列的网段进行匹配,如果发现匹配的行就按照那一行的路由规则进行转发,如果找不到匹配的,就按照default所在的行进行转发。一般来说,路由表里面能够列出来的网段都是电脑所在的局域网,例如上面的192.168.100.0/24就是我家里WiFi的地址,172.17.0.0/16是Docker容器的内网地址。一般来说只有对于这些局域网的地址操作系统才能准确知道应该怎么转发,对于其他情况,一般就要把包发给上级的路由器,这就是default一行表示的内容。

第一列后,路由表的每一行都是由一连串的属性组成的:

  • dev xxx表示应该这个IP包应该从名为xxx网卡(interface)发出。
  • via xxx表示这个IP包应该发往地址为xxx的网关/路由器并由后者进一步地路由。这一条一般在default规则中最常见,例如via 192.168.100.1中的192.168.100.1就是我家里WiFi的网关地址。
  • scope link表示目标地址在链路层可以直达,即不需要通过其他设备进一步转发了。这一条对于tun2socks的配置倒不是很重要。
  • src xxx表示这个IP包的源地址最好是xxx。这个属性只有在一个IP包还没有确定源地址(也就是刚刚发出)的时候才有效,如果一个IP包已经有了一个源地址,那么这条规则是不会去改的。
  • linkdown表示这一行对应的网卡设备处于离线状态。
  • proto xxx记录了这一行路由规则的来源。例如,proto kernel说明这一行是内核加的,proto dhcp说明这一行来源于DHCP。
  • metric xxx表示这一行路由规则的优先级,如果路由表里有好几行都能匹配目标地址,那么系统会按照metric较小的一行来转发。

到这里,我们就基本可以从ip route的输出看懂一张路由表了。

然而Linux里面的路由表并不只有一张(悲)。上面展示的路由表只是路由表(table main)。默认还有两张路由表:local表由内核自动维护我们一般不管;还有一张默认为空的default表。既然有好几张路由表,Linux怎么知道应该查那一张呢?这是由路由规则决定的,可以用ip rule show查看,默认的路由规则是:

0:  from all lookup local
32766:  from all lookup main
32767:  from all lookup default

冒号前的数字表示一条规则的优先级,可以取0到32767的整数,值越小优先级越高。from all表示这条规则匹配所有源地址的IP包(all也可以换成具体的IP或者IP段,类似的条件还有to all),lookup local表示去查local这张路由表。因此,上面ip rule的输出翻译成人话就是:

  1. 首先查local这张路由表,如果查到有一行匹配就按照这一行走;
  2. 如果local表匹配不上,接着查main表;
  3. 如果main表也匹配不上,就查default表;
  4. 如果default表也匹配不上,这包就寄了。

这就是Linux默认的路由规则,非常清楚(确信)。

到这里,你已经初步了解了Linux的路由规则,接下来让我们做一个小练习吧!

试一试:给 tun2socks 配置路由表

假设我们有一个叫tun0的TUN设备,其IP为198.18.0.1,我们能要怎么修改路由表才能让所有的流量都转发给它呢?

我们首先想到的是default行——既然之前default负责把所有非局域网的流量转发给路由器,那么它自然也可以转发给tun0,于是我们执行如下命令

ip route add default via 198.18.0.1

为了保险起见,我们可以删除原来的default(方便起见,这里的网关IP和dev都是我自己的设备,可以按照自己的情况进行调整):

ip route del default via 192.168.100.1 dev wlp4s0

但是只这么做会导致一个问题:

  1. 如果SOCKS 5的inbounds不在本机上,那么从tun0发出的代理请求现在也会被绕回到tun0,这下死循环了。
  2. 就算SOCKS 5的inbounds在本机上,它的下一个节点也会在海外,而发向这个节点的代理请求也会被绕回到tun0,还是死循环。

如何解决这个问题?我们需要确保代理服务器的发出的包还是走原来的路径。我们把原来的路径存在default表里:

ip route add default via 192.168.100.1 dev wlp4s0 table default

这里注意到我们在末尾加上了table default来指定修改的路由表,如果不加默认是table main

接下来,通过配置路由规则让代理服务器的包走原来的路径。

# 192.168.100.2是我的局域网IP,这条规则对应SOCKS 5入口不在localhost的情况
# (这个时候需要用tun2socks的 -interface 参数绑定出包用的设备,绑定了之后
# tun2socks会用这个设备发送所有发给SOCKS 5的请求,IP包上的源IP此时自然就是局域网IP)
ip rule add from 192.168.100.2 lookup default
# 或者如果SOCKS 5入口在localhost但是你知道远程节点的确切IP的时候也可以这样,
# 这可以确保到远程节点的连接永远是直连。
ip rule add to <代理服务器IP> lookup default

默认情况下,我们现在加的路由规则会有更高的优先级(local表的规则除外),现在所有的路由规则:

0:  from all lookup local
32764:  to <代理服务器IP> lookup default
32765:  from 192.168.100.2 lookup default
32766:  from all lookup main
32767:  from all lookup default

于是现在碰到所有确定源IP是192.168.100.2的包或者是发往远程节点的包Linux都会先查找default表,而default表里面唯一的规则确保这些包会直接发往原来的网关。

至此,启动tun2socks就可以享受全局代理了!当然在关闭tun2socks之后要记得用类似的命令将路由表复原(基本上就是adddel互换)

进阶:iptables和ipset

上面的配置虽然够用,却有很大的局限性:

  1. 如果我的远程节点不止一个,甚至是依据订阅动态变化的,导致一条条加to <远程节点IP> lookup default会很麻烦,怎么办?
  2. 如果我在本地开了一个v2ray而且在那里设置了国内地址直连,又通过tun2socks把所有来自本机的请求导到v2ray的SOCKS 5 inbounds里面,那么如何确保v2ray出来的流量不会再次走到tun2socks那里形成死循环?注意到因为设置了国内地址直连,所以v2ray outbounds的流量的目标地址可以是远程节点的地址加上任意的国内IP,很难设置路由规则。
  3. 如果我不想通过v2ray等辅助软件实现国内地址分流,应该如何操作?

为了解决这些问题,我们需要iptables。Iptables算是Linux的防火墙系统,但是实际上灵活性很高,它和路由表一样是Linux网络层路由体系的重要组成部分。二者的具体关系可以参照下图中的绿色部分:

Netfilter

Iptables默认有五个链(chain),这些链可以完成对包的过滤、地址转换以及标记。这五个链的作用分别是:

  1. 到达本机的包先通过PREROUTING链变换过滤,然后这些包才会按照路由表分派到本机或者继续转发。
  2. 继续转发的包通过FORWARD链变换过滤。
  3. 发给本机进程的包接着通过INPUT链变换过滤。
  4. 本机进程准备发包时,先按照路由表确定一个初步的路径,然后构造好的IP包经过OUTPUT链变换过滤。
  5. FORWARD和OUTPUT输出的包接着汇合,此时Linux会再次查找路由表确定出包的最终路径——对于本机发出的包,如果最终的路径和经过OUTPUT前确定的路径不同,以新确定的路径为准(但是并不会修改按照原路径确定的源IP地址,这也是一个坑)
  6. 在路由完成后,所有表经过POSTROUTING链再次变换/过滤才会被真正发出。

可以看到,如果我们需要过滤由代理应用发出来的包,这个过滤的逻辑应该在OUTPUT链上。Iptables和ip rule配合的方式是使用iptables给包加上特定的fwmark,并在ip rule当中通过fwmark匹配来进行分流。所以我们就有(我这里选择给要直连的包打上23333的fwmark,具体数字可以任意选):

iptables -t mangle -A OUTPUT <条件> -j MARK --set-mark 23333
ip rule add fwmark 23333 lookup default

其中-t mangle表示过滤条件设置到OUTPUT链的mangle子链上——iptables每一个链都有三四个子链,负责不同的功能,例如nat子链负责地址的转换,filter子链负责包的过滤,而这里的mangle子链负责对包进行加标记等变换。

如何设置iptables的条件?这和代理应用的特征有关。

过滤来自特定用户或PID的流量

比如在我的机子上代理是systemd用nobody用户运行的,于是我们就可以用iptables的owner模块写出:

iptables -t mangle -A OUTPUT -m owner --uid-owner nobody -j MARK --set-mark 23333

除了--uid-owner还有--pid-owner--gid-owner,可以适当地调整以适应自己代理进程的特征。

过滤特定目标地址的流量(ipset)

如果只是想配置国内地址直连,也可以过滤目标地址是特定网段的流量。国内地址的IP段比较广泛(一般是6000-8000个IPv4的网段),所以不能直接一条条用iptables输入。这个时候就需要用到ipset这一个工具。

我们先用ipset创建一个集合:

# cn4是这个IP集合的名字,hash:net是内部的数据结构
# hash:net是为每一个不同的prefix length创建一个
# 哈希表,所以查询时间关于不同prefix length的个数
# 是线性增长的,比较适合我们的情况(因为IPv4总共也不
# 过32个不同的prefix)
ipset create cn4 hash:net
ipset add cn4 1.0.1.0/24
ipset add cn4 1.0.2.0/23
...

这一部分可以适当地用脚本自动化一波。如何获取国内所有IP段的列表以及如何自动化得添加超出了这个博文的范围。

然后我们在iptables里面加入如下规则:

iptables -t mangle -A OUTPUT -m set --match-set cn4 dst -j MARK --set-mark 23333

--match-set cn4 dst表示匹配所有目标地址是cn4集合里面的包。

修复:改变IP地址