最近回国了就惦记起科学上网的事情了。很多科学上网工具的 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 地址