透明代理 - iptables, TPROXY 和 v2ray

2023.05.27 更新:

有人认为 v2ray 已经逐渐难以维护,并建议更换到 sing-box。我个人觉得 v2ray 变得难以使用的点在于,它推出新的 v5 配置格式已经过了一年,但 v5 配置的文档依旧乱七八糟,甚至 typo 数量都很可观。并且很多有用的旧特性在 v5 版配置中消失了,而很多新的特性却又只能在 v5 里用。

切换到 sing-box 后,全局代理也有了新的方案:Tun。相比于手搓 iptables,tun 只操作路由(而且是自动配置的;可以通过 ip-rule 和 ip-route 查看),冲突的可能性更小,并且据说sing-box 的 tun 对 udp 具有更好的性能。

综上,本文可能已经不再具有实用意义。

关于 iptables ​

以下是一些概念上和常见使用方法的要点,看完后可能还是不会用但能理解重要概念。具体详情可以看 archwiki 和 man。

  • 有 filter, nat, mangle, raw, security 等 table ,每个 table 含有若干个 chain

  • 当一个包从某个 interface 进来时,会以这张图(后续还会多次提到这张图的内容)所示的顺序经过各个 chain

  • 图中第一个 routing decision 取决于包的目的地是否是本机。如果是,则进入 INPUT chain ,否则进入 FORWARD chain

  • 可以在 chain 里写入-A(ppend) / -I(nsert) rule 来对包进行操作。rule 会被逐个遍历,方式如这张图

    • rule 里可以写匹配规则来实现包的过滤,如 -s|-d <IP range> 匹配 source/dest ip ,-p <protocol> 匹配协议等。用 ! 来反匹配

    • 用 -j TARGET 来指定要对匹配到的包进行的操作

      • 内建的 TARGET 有 ACCEPT(停止遍历当前 table 的所有 rule,进入下一个 table 的 chain),DROP(直接丢掉)和 RETURN(停止遍历当前 chain 的 rule,返回调用当前 chain 的上一个 chain,继续遍历)等,具体见 man iptables

      • 有若干 extension 可以使用,见 man iptables-extensions。也可以将自定义的 chain 作为 TARGET。

      • -j LOG --log-prefix "netfilter " 很适合用来输出日志帮助调试。日志可以用 dmesg -S | grep netfilter 来查看

  • ufw 是通过 iptables 中的 filter table 来实现的。开启 ufw 后可以通过 iptables -t filter -L 来查看其规则。配置 iptables 前先关闭 ufw,然后用 iptables -t filter -F 清空非系统默认的 rule,iptables -t filter -X 删除非系统默认的空的 chain。

关于 Linux 包的转发 ​

多数 linux 发行版默认应该是不开启转发的。可以通过 sysctl net.ipv4.ip_forward=1 开启 IPv4 的包的转发。开启之后目的地非本机的包才会如上文所述进入 FORWARD chain。开启这个选项之后机器就已经可以做网关了,虽然还没有任何路由或代理功能。

对局域网设备实现透明代理 ​

注:此篇所写的透明代理使用 iptables 的 TPROXY,所涉及的规则均在 mangle table 中。另有通过 nat table 实现的透明代理,暂未了解。

用 linux 机器做透明代理,一般是把机器设置为局域网中其他设备的网关,然后在机器上对“来自其他设备并且目的地在局域网以外的包”进行代理。这种情况下这个机器充当了局域网中的旁路由。通常的做法是将来自局域网其他设备的包转发给机器上代理程序的本地进程。问题是本机进程所在的 chain 是 INPUT,而我们要处理的包因为目的地不是本机而被路由至 FORWARD chain;因此我们要在 PREROUTING 和 routing decision 时设法让这些包进入 INPUT chain,并转发至目标进程所在的端口。

bash
# 在 mangle table 中自定义一个叫 V2RAY 的 chain
iptables -t mangle -N V2RAY
# 在 mangle table 的 V2RAY chain 中附加规则:
# 匹配 tcp|udp protocol
# 用 TPROXY target 指定目标端口(代理程序所在端口)12345,并打上标记 fwmark 1
iptables -t mangle -A V2RAY -p tcp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
iptables -t mangle -A V2RAY -p udp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
# 将 V2RAY chain 附加在 PREROUTING chain 中
iptables -t mangle -A PREROUTING -j V2RAY

# 创建 id 为 100 的 table,将 fwmark 为 1 的包关联至此 table
ip rule add fwmark 1 table 100
# 在 table 100 中添加规则:将所有(0.0.0.0/0)包重定向到 loopback 中(从而进入 INPUT)
ip route add local 0.0.0.0/0 dev lo table 100

这样一来,PREROUTING 中的包就会到达端口 12345 了。问题是我们并不需要代理所有的包,例如目标地址在局域网中的包,以及公网中的连接对象返回给我们的包。因此我们要在 TPROXY 之前,通过包的目的地来过滤,只代理“目的地不在局域网中的包”。可以在网关机器上用 ip a | grep -w "inet" | awk '{print $2}' 来显示当前所有 interface 的 IP 范围。输出示例:

127.0.0.1/8
192.168.0.2/24

那么我们需要写的规则是:

bash
iptables -t mangle -N V2RAY

# 用目的地 IP 匹配不需要代理的包
# 用 RETURN 退出 V2RAY chain
iptables -t mangle -A V2RAY -d 127.0.0.1/8 -j RETURN
iptables -t mangle -A V2RAY -d 192.168.0.2/24 -j RETURN
# Broadcast address
iptables -t mangle -A V2RAY -d 255.255.255.255/32 -j RETURN
# Multicast address
iptables -t mangle -A V2RAY -d 224.0.0.0/4 -j RETURN

iptables -t mangle -A V2RAY -p tcp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
iptables -t mangle -A V2RAY -p udp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
iptables -t mangle -A PREROUTING -j V2RAY

ip rule add fwmark 1 table 100
ip route add local 0.0.0.0/0 dev lo table 100

接下来需要配置代理程序来处理接收到的这些包。这里使用 v2ray 的 dokodemo-door 协议:

json
{
  "inbounds": [
    {
      "port": 12345,
      "protocol": "dokodemo-door",
      "settings": {
        "network": "tcp,udp",
        "followRedirect": true // 此选项配合 TPROXY 使用以保留目标地址
      },
      "sniffing": {
        "enabled": true /*, // 透明代理时,v2ray 接到的所有包都是解析为 ip 之后的;
                            // 开启嗅探可以一定程度还原出域名信息,方便进行路由。

        "destOverride": [   // 如果开启目标覆盖,v2ray 会重新用嗅探到的域名来连接,
          "http", "tls"     // 就会使用远端 DNS 解析 ip,但重复解析略显浪费
        ]*/                 // 这里没有开启,稍后会手动处理 DNS
      },
      "tag": "transparent",
      "streamSettings": {
        "sockopt": {
          "tproxy": "tproxy"
        }
      }
    }
  ],
  "outbounds": [
    {
      "tag": "proxy",
      "protocol": "..."     // 你的代理出站配置
    },
    {
      "tag": "direct",
      "protocol": "freedom"
    }
  ],
  "routing": {
    "domainStrategy": "AsIs",
    "rules":[ 
      {
        "type": "field",
        "port": 123,        // ntp protocol 所用端口,直连
                            // 抄自 v2ray 新白话文教程
        "inboundTag": "transparent",
        "network": "udp",
        "outboundTag": "direct"
      }
    ]
  },
  "other": {}
}

将 v2ray 运行起来,不出意外的话此时局域网设备已经可以透明代理上网了。需要注意的是,如果一个网络资源的 ip 是公网 ip 的形式,但却只限定内网设备访问,此时你的设备就不能访问了。如果这个资源是个 DNS 服务器(点名某学校的 DNS 服务器),你甚至几乎完全不能上网。解决办法是将这个 ip 单独写进 iptables 过滤掉。对于 DNS 服务器的情况,则可以通过下面部分解决。

劫持 DNS 请求 ​

由于透明代理的特性,客户端程序是不知道自己在代理后面的,因此不同于一些代理协议可以直接使用远端 DNS,透明代理中客户端程序是一定会自己先做一次 DNS 查询的。之所以说是“先”做一次,是因为如 v2ray 之类的后续代理程序有可能通过嗅探得到域名,再做一次额外的查询。而理想的情况是所有的 DNS 请求都通过代理进行,以避免由本地查询 DNS 造成的泄露。如果局域网内默认的 DNS 服务器就是可信的公共服务器,那么根据上面的配置,向这些公网服务器发出的查询请求已经是通过代理发出的了;但很多局域网都并非如此。因此我们可以在网关机器配置 iptables 规则,通过将所有 DNS 查询请求转发给指定程序来实现 DNS 劫持。这样一来终端设备并不需要逐一修改 DNS 配置,而对集中到网关机器的 DNS 请求我们也更方便进行一些复杂的操作(如分流等等)。

diff
iptables -t mangle -N V2RAY
iptables -t mangle -A V2RAY -d 127.0.0.1/8 -j RETURN
- iptables -t mangle -A V2RAY -d 192.168.0.2/24 -j RETURN
+ # 过滤掉局域网内的请求,除非目标端口是 53
+ iptables -t mangle -A V2RAY -d 192.168.0.2/24 -p tcp ! --dport 53 -j RETURN
+ iptables -t mangle -A V2RAY -d 192.168.0.2/24 -p udp ! --dport 53 -j RETURN
iptables -t mangle -A V2RAY -d 255.255.255.255/32 -j RETURN
iptables -t mangle -A V2RAY -d 224.0.0.0/4 -j RETURN

iptables -t mangle -A V2RAY -p tcp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
iptables -t mangle -A V2RAY -p udp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
iptables -t mangle -A PREROUTING -j V2RAY

ip rule add fwmark 1 table 100
ip route add local 0.0.0.0/0 dev lo table 100

这里为了简便,DNS 请求也全部交给 v2ray 来处理。v2ray 的配置是这样的(其余配置略):

json
{
  "dns": {
    "servers": ["1.1.1.1"]
  },
  "outbounds": [
    {
      "protocol": "dns",
      "tag": "dns-out",
      "settings": {
        "address": "1.1.1.1"
      }
    }
  ],
  "routing": {
    "rules": [
      {
        "type": "field",
        "inboundTag": "transparent",
        "port": 53,
        "outboundTag": "dns-out"
      }
    ]
  }
}

这里通过使用 dns 出站协议来实现将请求服务器改写为"1.1.1.1"。需要注意的是 v2ray 的 dns 出站协议是这样工作的:对于 A 和 AAAA 类查询,转发给内置 DNS 服务器;其余查询转发至 settings 中指定的目标地址;如果没有指定则转发至原本的目标地址。至于每个 DNS 服务器使用哪个出站来连接,是和其他的普通连接一样根据 routing 来处理的。因此上述这个简单的配置的效果就是对于所有的 DNS 查询,均使用"1.1.1.1"覆盖原服务器地址,并用默认出站连接。由于如上文所述没有开启 destOverride,后续连接均直接使用 IP 连接。

如果想对 DNS 请求也进行分流,可以参考 v2ray 内置 DNS 服务器的更多配置。但是 v2ray 只支持 A 和 AAAA 类型请求的分流。如果需要更复杂的 DNS 配置,可以了解如 CoreDNS 之类的项目。

对网关本机进行代理 ​

完成以上的配置后,网关本机可以正常上网,但并没有经过代理,原因是本地进程发起的连接只经过 OUTPUT -> POSTROUTING chain;而 TPROXY 只能在 PREROUTING 中使用。可以通过让将 OUTPUT 的包重新经过 PREROUTING 的办法来实现对网关本机的代理。

bash
iptables -t mangle -N V2RAY_MASK
iptables -t mangle -A V2RAY_MASK -d 127.0.0.1/8 -j RETURN
iptables -t mangle -A V2RAY_MASK -d 192.168.0.2/24 -p tcp ! --dport 53 -j RETURN
iptables -t mangle -A V2RAY_MASK -d 192.168.0.2/24 -p udp ! --dport 53 -j RETURN
iptables -t mangle -A V2RAY_MASK -d 255.255.255.255/32 -j RETURN
iptables -t mangle -A V2RAY_MASK -d 224.0.0.0/4 -j RETURN
# 过滤掉代理程序发出的包
iptables -t mangle -A V2RAY_MASK -m cgroup --path system.slice/v2ray.service -j RETURN
# 打 fwmark 标记;根据已经配置的路由规则,标记的包会重回 loopback,从而经过 PREROUTING
iptables -t mangle -A V2RAY_MASK -p tcp -j MARK --set-mark 1
iptables -t mangle -A V2RAY_MASK -p udp -j MARK --set-mark 1
# 将 chain 附加到 mangle table 的 OUTPUT chain
iptables -t mangle -A OUTPUT -j V2RAY_MASK

以上配置大部分与 PREROUTING 类似,唯一要注意的是过滤掉代理程序发出来的包以免形成回环。这里因为我的 v2ray 是 systemd 下的服务,因此采用了 cgroup 来进行匹配。可行的方法有很多,例如新白话文教程中在 v2ray 的出站配置里设置 fwmark 等。