使用 iptables 的 tproxy 完成本机访问家里内网的透明代理

最近正在尝试将主力笔记本从 Mac 换到 Linux,首先要解决的就是网络问题 (桌面美化)。本文介绍了笔者在使用 Linux 内核支持的 tproxy(Transparent proxy)让本机(手里的笔记本)访问外网流量时通过 tproxy 完成,同时本机在家里内网环境时访问内网流量不经过 tproxy,在其他环境下,访问的家里内网流量经过 tproxy。

  1. 由于笔者没有看过完整的计算机网络,甚至连 TCP 握手是什么都不知道,导致很多内容的可能不准确/有错误。
  2. 光看个 tproxy 配置不理解其中的含义,很可能导致配置出来的有问题,所以本文会尽可能写出我对每一行命令(配置)的理解,这样才能在遇到各种网络环境/需求下,都能得心应手。

# NAT(Network address translation)是什么?

在介绍 tproxy 之前,我们需要先大致知道 NAT 是什么。可能因为 IP 地址的短缺,无法为每台设备都提供的一个全球独立的 IP,NAT 应运而生。

我们来画个图,看一下 NAT 是如何帮助一台内网的设备和外网的服务进行通信的。

假定我们的内网 IP 段为 192.168.22.0/24(192.168.2.0-192.168.22.255),内网网关(路由器)拥有两个接口,一个对应内网的 192.168.22.1,一个是公网 119.3.70.188,我们要访问的服务拥有的 IP 为 120.92.78.97。

我们的设备(192.168.22.33)会向网关发一个 IP 数据包,这里包含了两个关键信息,目的地 IP(120.92.78.97)和来源 IP(192.168.22.33),这样一来网关知道这个数据该给谁,数据回来的时候又该送回哪里。

网关进行一次 SNAT 转换,将来源 IP 换成自身的公网 IP,这样发给服务器的数据服务器才能知道响应给谁。服务器收到数据后,会返回一个数据包,这里仍然会标记上来源 IP 为服务器的 IP。响应到网关的数据包,网关进行一个 DNAT 转换,将目标地址 IP 换成我们的设备 IP,并将数据包发给设备。

从以上的过程中,我们可以得到两个重要结论:

  • 一次网络数据交换是有来有回
  • 从本机发出去和接收的数据中,来源 IP 和目标 IP 都是外网 IP 和本机 IP

我们带着这两个结论来看数据包是如何从本机出去的,回来的数据包又在本机上经过了什么?

# OUTPUT 和 PREROUTING chain

这里不会详细介绍 iptables 的 table 和 chain,文末链接中有大量相关的内容介绍。

从本机出去的数据包会经过 OUTPUT 链,而从外部发往本机的数据包会经过 PREROUTING 链(当然也会经过 INPUT 链,但因为 tproxy 只能工作在 PREROUTING 链,所以我们只关心 PREOUTING 链),这对应了上面的有来有回,为了区分各种场景的数据包,我们会用到来源 IP 和目标 IP,比如在 OUTPUT 链中,目标 IP 为外网时,应当经过 tproxy。 由于 tproxy 只能工作在 PREROUTING 链,从本机发出去的数据包会直接通过 OUTPUT 链出去了,本机数据包没有机会走一下 PREROUTING 链。

为了让本机数据经过 PREROUTING,我们可以用的路由表解决(这部分我没有完全理解指令每个参数的含义,但大致够用了)。

执行如下命令:

# ip rule add fwmark 23 table 2233
# ip route add local 0.0.0.0/0 dev lo table 2233

第一行添加一条 ip 规则,表示被标记为 23 的的数据包的走向要查表 2233。 第二行大致是说对于 2233 表的流量,都要发到本机(经过本机的 PREROUTING)。

当我们给 OUTPUT 链的数据包进行标记时,这个数据包会重新查一次表,如果我们标记的刚好是 23,那这个数据包就会根据上面的规则进入本机的 PREROUTING。这样一来,我们就可以将本机的数据包发送的 tproxy。

在 OUTPUT 链上打标记命令如下:

# iptables -t mangle -A OUTPUT -p tcp -j MARK --set-mark 23
# iptables -t mangle -A OUTPUT -p udp -j MARK --set-mark 23

解释一下上面的两条命令,iptables 中有三个常用的表,filter、nat 和 mangle,三个表有各自的用途,能链的作用范围也不一样,mangle 表支持在 OUTPUT 链上打标记。

这里有一堆缩写参数,导致理解起来比较困难,如下是相关解释:

  • -t/--table [table]:表示这条规则作用在哪个表上
  • -A/--append [chain]:表示给这个链添加一条规则
  • -p/--protocol [protocol]:表示处理哪个协议
  • -j/--jump [target]:表示跳转到哪个目标

比如上面的第一条命令表示,在 OUTPUT 链上的 tcp 协议都跳转到 MARK 并标记个 23。 此时被标记为 23 的数据包会重新走到 PROROUTING 链上。

tproxy 仅能处理 tcp 和 udp,所以这里只标记这两个协议。

# 使用 chain 管理数据包

上面的规则虽然完成了重回 PREROUTING 的能力,但有些数据包应当直接从 OUTPUT 链出去,比如访问网关管理页面(192.168.22.1),或者是 NAS 的 192.168.22.2。所以我们需要跳过一部分的数据包避免标记上 23。

# iptables -t mangle -A OUTPUT -d 192.168.0.0/16 -j RETURN

-d/--destination [address] 表示匹配对应的 ip(范围),上面的命令则是在 OUTPUT 链上,如果是发往 192.168.0.0/16 的数据包,则直接返回。这一条命令要在前面的 -j MARK 命令之前执行,匹配规则会根据创建的规则顺序按顺序执行。

如下,这表示去往非 192.168.0.0/16 的 tcp/udp 数据包标记 23:

# iptables -t mangle -A OUTPUT -d 192.168.0.0/16 -j RETURN
# iptables -t mangle -A OUTPUT -p tcp -j MARK --set-mark 23
# iptables -t mangle -A OUTPUT -p udp -j MARK --set-mark 23

此时我们可以思考一下 OUTPUT 链 “本质” 是什么: 写 iptables 的规则,就是在写代码,OUTPUT 链是程序内置的函数,它会在对应时机调用,我们需要做是填充这个函数的逻辑。(匹配规则就是 if else) 所以,链 = 函数。这就清晰很多了。 iptables 还给我们提供了创建自定义链(函数)的能力,有了自定义链(函数)我们可以更好地管理规则。

对于上面的 OUTPUT 管理,我们可以改写成:

# iptables -t mangle -N LOCAL_DIVERT
# iptables -t mangle -A LOCAL_DIVERT -d 192.168.0.0/16 -j RETURN
# iptables -t mangle -A LOCAL_DIVERT -p tcp -j MARK --set-mark 23
# iptables -t mangle -A LOCAL_DIVERT -p udp -j MARK --set-mark 23

-N/--new-chain [chain] 表示创建一个新的链。

我们创建了一个自定义链 LOCAL_DIVERT,接下来需要跳转到这个链(调用这个函数):

# iptables -t mangle -A OUTPUT -j LOCAL_DIVERT

你也可以带着这个函数的感觉,理解一下 ACCEPT。

当不需要这些规则时,你可以删除相关的链和规则: 比如:

# iptables -t mangle -D LOCAL_DIVERT -d 192.168.0.0/16 -j RETURN

这会删除对应的一条规则。删除的逻辑我们需要捋清楚,当关闭 tproxy 时,全删除不是合理做法,特别是你还写了一些其他规则,比如限制指定应用联网。

当不需要 LOCAL_DIVERT 时,我们可以通过以下命令删除(要小心翼翼):

# iptables -t mangle -D OUTPUT -j LOCAL_DIVERT
# iptables -t mangle -F LOCAL_DIVERT
# iptable -t mangle -X LOCAL_DIVERT

三条命令首先删除跳转(调用)的引用,然后删除 LOCAL_DIVERT 中的全部规则,然后删除 LOCAL_DIVERT 这个链。

以上几个缩写为:

  • -D/--delete:从一个链上删除指定的规则
  • -F/--flush [chain]:清空一个链上所有规则
  • -X/--delete-chain [chain]:删除一个链

# 应用 tproxy

有了以上的内容,我们可以开始把数据发的 tproxy,tproxy 翻译过来就是内核提供的透明代理能力。 这里我们使用 clash (opens new window) 接管 tproxy 的流量。 根据 clash 的文档,假定我们设置的 tproxy 端口为 22223,如下命令可以将数据包通过 tproxy 发给 clash:

# iptables -t mangle -A PREROUTING -p tcp -j TPROXY --on-port 22223
# iptables -t mangle -A PREROUTING -p udp -j TPROXY --on-port 22223

如果你看过一些文档,可能注意到 --tproxy-mark 参数,这个参数会给跳转到 TPROXY 的数据包进行一个标记,你可以用这个标记处理后续的判断,一般可以用来判断回环流量(很快我们就会遇到这个问题)。

记得创建自定义的链,上面的两行命令 PREROUTING 在增加更多的规则后会很难管理。

当前完整的 iptables 命令如下:

# iptables -t mangle -N DIVERT
# iptables -t mangle -A DIVERT -d 192.168.0.0/16 -j RETURN
# iptables -t mangle -A DIVERT -p tcp -j TPROXY --on-port 22223
# iptables -t mangle -A DIVERT -p udp -j TPROXY --on-port 22223
# iptables -t mangle -A PREROUTING -j DIVERT

# iptables -t mangle -N LOCAL_DIVERT
# iptables -t mangle -A LOCAL_DIVERT -d 192.168.0.0/16 -j RETURN
# iptables -t mangle -A LOCAL_DIVERT -p tcp -j MARK --set-mark 23
# iptables -t mangle -A LOCAL_DIVERT -p udp -j MARK --set-mark 23
# iptables -t mangle -A OUTPUT -j LOCAL_DIVERT

你可能注意到这里第二行多了个命令,记得有来有回,回来的数据包不能再走一次 tproxy 了!由于我们的本机 IP 为 192.168.22.33,在这个范围内,上面的规则看起来没什么问题。

实际上这些规则是不能用的,看起来没问题,但从 clash 出来的数据包呢? 上面的规则会变成:

  • 本机发出来去往外网的数据包通过 mark 回到 PREROUTING
  • 通过 tproxy 进入 clash
  • 本机 clash 发出来去往外网的数据包通过 mark 回到 PREROUTING
  • 通过 tproxy 进入 clash
  • 本机 clash 发出来去往外网的数据包通过 mark 回到 PREROUTING
  • 通过 tproxy 进入 clash
  • 本机 clash 发出来去往外网的数据包通过 mark 回到 PREROUTING
  • .....

# 解决回环问题

这里产生了一个回环,我们需要解决掉,即让本机 clash 发出来的数据包直接从 OUTPUT 链出去吧

根据文末的一些文档,通过 mark 匹配的性能似乎不太好(我没有去验证)。我们采取另一种方式,通过 gid/uid 绕过回环。

思路:由于 Linux 的多用户模式,我们可以让一个单独的用户启动 clash,然后利用 iptables 中匹配 gid/uid 的功能绕过回环,当是 clash 对应的用户的流量,直接从 OUTPUT 链出去。

使用以下命令可以创建一个名为 clash,uid 为 0,gid 为 23333 的用户:

# grep -qw clash /etc/passwd || echo "clash:x:0:23333:::" >> /etc/passwd

使用如下命令可以验证创建的用户是否正常:

$ sudo -u clash id

接下来我们使用这个用户启动 clash:

$ sudo -u clash /usr/local/bin/clash -d /etc/clash

iptables 执行在 OUTPUT 链改为如下命令:

# iptables -t mangle -A OUTPUT -m owner ! --gid-owner 23333 -j LOCAL_DIVERT

-m/-match 表示匹配对应的策略,上面的命令则是在 OUTPUT 链 gid 非 23333 的数据包跳转到 LOCAL_DIVERT。

# clash 使用 tproxy 需要劫持 DNS

使用 clash 的 tproxy 能力时,需要让 clash 接管 DNS 流量(似乎是用来判断路由规则?),有两种方式:

  • 停掉本地的 DNS 服务,将 clash 的 DNS 服务端口改为 53
  • 将 DNS 查询数据包转发到 clash

这里我们采取第二种:

# iptables -t nat -N LOCAL_DNS_DIVERT
# iptables -t nat -A LOCAL_DNS_DIVERT -p udp --dport 53 -j REDIRECT --to-ports 1053
# iptables -t nat -I OUTPUT -m owner ! --gid-owner 23333 -j LOCAL_DNS_DIVERT

1053 是在 clash 上设置的 DNS 服务端口。 -I/--insert [chain] [rule index] 表示插入一条规则到指定位置,默认为 1,表示插入到规则的第一条。这里使用 -I 是避免后续我们添加更多的 iptables 规则后,影响到 DNS 数据包转发。

记得这里同样需要仅拦截来自非 clash 的数据

有了以上的配置,我们已经可以完成基本的操作:将本机发往外网的数据通过 tproxy 经过 clash 发出,并避免数据回来时又通过 tproxy 进入 clash。

# 绕过更多内网 IP 段

还差一步,这些配置就可以用了,上面的操作中,发往 127.0.0.1:1053 的 DNS 查询数据包会通过 OUTPUT 链回到 PREROUTING 最后进入到 tproxy。 我们可以在 OUTPUT 链跳过去:

# iptables -t mangle -A LOCAL_DIVERT -d 127.0.0.0/8 -j RETURN

回来数据包仍然经过 PREROUTING,同样需要跳过:

# iptables -t mangle -A DIVERT -d 127.0.0.0/8 -j RETURN

我们还有更多的内网/特殊 IP 段需要直接 RETURN,完整列表如下:

100.64.0.0/10
127.0.0.0/8
169.254.0.0/16
192.0.0.0/24
224.0.0.0/4
240.0.0.0/4
255.255.255.255/32
192.168.0.0/16
172.16.0.0/12
10.0.0.0/8

由于到这一步,我们完成了最基本的配置,避免影响阅读全部的内容,我将当前的完整配置记录到 Gist (opens new window)

此时在访问外网网站时,应当可以看到 clash 中的日志,也可以看到数据正常返回(curl 成功、网站加载成功)。 如果没有,注意检查上述有哪些地方有错或是漏了,比如 ip rule/route 的丢失。

# 访问家里内网

这部分要基于 使用 Shadowsocks 访问家庭内网 (opens new window) 完成,你需要先搭建一个 ss-server 供你在其他网络环境下访问家里内网。 这个场景下有一个问题,如何在家直连,在外面走 SS? 文章中给出了一个能用但不够优雅的方案,将家里的 DNS 服务把域名指向内网 IP,不管在哪里,流量都经过 clash。这可能带来一些问题,upnp 等需要直接连接的内网流量异常(只是可能,我还不确定)。 接下来我们来看看如何解决这个问题,初步的想法可能是监听 IP 变化,如果在家里内网,访问内网的数据包都不要在 OUTPUT 链打标记,如果在外面,当作外网一样处理。这么处理太麻烦了,实际上我们可以通过 iptables 的匹配规则完成(iptables 可以曲线完成 if else)。

首先看 OUTPUT 链,这个相对简单一些,如果来源 IP 不在家里内网段(192.168.22.0/24),同时目标 IP 是家里内网段,打标记。

# iptables -t mangle -N LOCAL_INTRANET_DIVERT
# iptables -t mangle -A LOCAL_INTRANET_DIVERT -p tcp -j MARK --set-mark 23
# iptables -t mangle -A LOCAL_INTRANET_DIVERT -p udp -j MARK --set-mark 23
# iptables -t mangle -A LOCAL_DIVERT -d 192.168.22.0/24 ! -s 192.168.22.0/24 -j LOCAL_INTRANET_DIVERT

! 表示非,同时注意该规则需要应用在 -d 192.168.0.0/16 的前面。

配置之后,如下的几种场景结果为:

  • 家里内网环境,访问内网,不会跳转到 LOCAL_INTRANET_DIVERT,因为来源 IP 在内网段
  • 家里内网环境,访问外网,不会跳转到 LOCAL_INTRANET_DIVERT,因为目标 IP 不在内网段
  • 非家里内网环境,访问内网,跳转到 LOCAL_INTRANET_DIVERT
  • 非家里内网环境,访问外网,不会跳转到 LOCAL_INTRANET_DIVERT,因为目标 IP 不在内网段

非常棒!

接下来可能会很绕,而且我可能阐述的不太清楚,需要多理解分析这里的各种场景。

接下来我们看 PREROUTING 链的处理,这个比较复杂,我们需要处理以下几种场景:

  • 从 OUTPUT 链来的数据包,目标 IP 为外网
  • 从 OUTPUT 链来的数据包,目标 IP 为家里内网
  • 响应回来的数据包,目标 IP 为本机 IP

事实上这里有很多种处理的方式,比如在 OUTPUT 链上打一个不同的标记,比如 33,然后在 PREROUTING 链匹配。这里我选择继续用 IP 匹配。

考虑到一份配置可能应用到多个设备中(或者一台设备的 IP 会变化),我们先看这种场景的处理:

# iptables -t mangle -N INTRANT_DIVERT
# iptables -t mangle -A DIVERT -d 192.168.22.0/24 ! -s 192.168.22.0/24 -j INTRANT_DIVERT

先进行第一步过滤,如果在 PREROUTING 链来源 IP 不是内网,目标 IP 是内网,接下来还有以下几种情况需要处理:

  • 设备处于家里内网环境,这是响应回来的数据,此时来源 IP 应当为外网 IP
  • 设备处于其他环境,这是从 OUTPUT 链来的数据,此时来源 IP 应当为其他内网 IP

我们需要在 INTRANT_DIVERT 链中进行过滤,当来源 IP 为内网时,走 tproxy(注意前面已经确保了来源 IP 不是家里内网):

# iptables -t mangle -A INTRANT_DIVERT -s 10.0.0.0/8 -p tcp -j TPROXY --on-port 22223
# iptables -t mangle -A INTRANT_DIVERT -s 10.0.0.0/8 -p udp -j TPROXY --on-port 22223
# iptables -t mangle -A INTRANT_DIVERT -s 172.16.0.0/12 -p tcp -j TPROXY --on-port 22223
# iptables -t mangle -A INTRANT_DIVERT -s 172.16.0.0/12 -p udp -j TPROXY --on-port 22223
# iptables -t mangle -A INTRANT_DIVERT -s 192.168.0.0/16 -p tcp -j TPROXY --on-port 22223
# iptables -t mangle -A INTRANT_DIVERT -s 192.168.0.0/16 -p udp -j TPROXY --on-port 22223

此时配置已经完成了,不过你可以会有疑问,在其他内网环境下,访问自身所在的内网环境怎么办? 注意前面的这一条规则:

# iptables -t mangle -A DIVERT -d 192.168.22.0/24 ! -s 192.168.22.0/24 -j INTRANT_DIVERT

如果在其他内网环境(比如 192.168.1.0/24),在该内网环境下,设备 IP 可能为 192.168.1.56,访问该内网回来的数据包的目标 IP 为 192.168.1.56,并不会匹配到这条规则进入 INTRANT_DIVERT。

# 解决其他网络环境下查询内网 DNS 问题

虽然 iptables 的规则都完成了,但还不能解决在其他网络环境下访问内网的问题,可以看到这里我们都是基于 IP 进行的判断,在其他网络环境下,根本查询不到 NAS 使用的域名对应的 IP。因为查询不到 IP,第一步的 DNS 查询就失败了,也无法走到后面的 IP 匹配。 此时只能通过 IP 访问内网环境。

为了解决这个问题,我们可以使用以下两个方法:

  • 在公网 DNS 服务配置映射规则,这是可以的,反正映射到内网 IP,还是安全的
  • 在 clash 的 hosts 中配置映射规则

此时应当可以看到,在家访问内网服务,clash 没有访问日志,在外面访问内网服务,可以看到数据经过了 clash。

到这一步,我们完成了所有的配置,我将当前的完整配置记录到 Gist (opens new window)

# 兼容 fake-ip 场景

clash 的 DNS 服务有两种类型,一种是 redir-host,另一种是 fake-ip,上述例子一直介绍的是 redir-host。 在这种类型下,所有 DNS 解析在本机完成,这可能遇到一些 DNS 污染问题,同时使用 fake-ip 让 DNS 解析在远程服务器完成会更快一些。

从使用上 fake-ip 的原理差不多是这个样子,首先定义一个 Fake IP 池(198.18.0.1/16),所有的 DNS 查询服务都直接返回这个池子里的 IP,此时可以做到如果通过域名匹配到某条规则不是直连,可以将 DNS 放到远程服务器解析,借此解决 DNS 污染问题。

这种场景下,因为所有返回的(目标) IP 都变成了 198.18.0.1/16,我们无法区分是内网还是外网,我们需要更新一下 clash 的配置,在 DNS 服务中,提供了 fake-ip-filter 参数,在这里面的域名将保持在本地查询 DNS 同时返回真实的 IP。

iptables 规则可以(这不是必要操作)做适当调整:

# iptables -t mangle -A DIVERT -p tcp -d 198.18.0.0/16 -j TPROXY --on-port 22223
# iptables -t mangle -A DIVERT -p udp -d 198.18.0.0/16 -j TPROXY --on-port 22223
# iptables -t mangle -A LOCAL_DIVERT -p tcp -d 198.18.0.0/16 -j MARK --set-mark 23
# iptables -t mangle -A LOCAL_DIVERT -p udp -d 198.18.0.0/16 -j MARK --set-mark 23

需要注意的时,当增加 -d 198.18.0.0/16 时,一些通过 IP 连接的服务不会走到 tproxy。(比如 Telegram 直连外网 IP,这种情况自然匹配不到 198.18.0.0/16)。

# 稍微改改

对上述规则我们还可以再走一步,当我们在家里内网环境下,访问外网服务时,响应回来时,INTRANT_DIVERT 中的规则总会被尝试匹配。

有两种可行的解决办法:

  1. 固定本机 IP,这样就可以增加一条规则,目标 IP 为本机 IP 时,永远直接 RETURN
  2. 固定要访问的内网 IP,比如指定 192.168.22.1/28 为需要访问的内网段,然后本机 IP 不要分配到这个网段

# 参考内容

以上配置满足了我的使用需求,但很多内容的介绍并不准确,比如 NAT 只使用 IP 做转换是不够的,tproxy 和 REDIRECT 之间有什么区别?

多亏以下参考内容,我才能完成本次的 iptables 应用:

  1. Linux iptables Pocket Reference (opens new window)
  2. Network address translation (opens new window)
  3. iptables(8) - Linux man page (opens new window)
  4. Transparent proxy support (opens new window)
  5. 新 V2Ray 白话文指南 - 透明代理(TPROXY) (opens new window)
  6. 透明代理通过 gid 规避 Xray 流量 (opens new window)
  7. [求助] 透明代理时Tproxy模式代理自身流量问题 (opens new window)
  8. 计算机网络:NAT基本原理 (opens new window)
  9. Delete a iptables chain with its all rules (opens new window)
  10. How To List and Delete Iptables Firewall Rules (opens new window)

最后你还可以把这套方案应用的 Android 上。 对了,如果你遇到 Arch/Manjaro 下提示的网络问题,那是 clash 的 fake-ip 模式不会响应 ICMP 协议 。你可以从 Checking connectivity (opens new window) 找到其中一种解决方法。 如果你想了解更多关于 iptables 内容,参考内容的第一条是一本很适合上手的书。 如果你想了解如何开机启动,参考内容的第 5、6 条会有一些帮助。