Armbian + R2S 番外篇:透明代理

透明代理这个东西说起来很容易理解,就是设备感知不到有的流量被代理,却有开了代理的效果。这个东西对于学习境外内容的体验提升很大,因为部分应用并不遵循系统代理设置,或设置很麻烦。避免将代理配置文件存放在电脑上,也可以防止电脑上的应用窃取代理信息。

透明代理本身在中文互联网已经是聊烂了的话题,但使用Nftables实现的透明代理很少,在主路由上实现透明代理的更少。我的网络拓扑大概是:外部网络 - R2S - 无线AP - 其他设备。本文将基于这个拓扑,实现在R2S上使用Nftables实现透明代理。

这个东西作为上篇文章的一个小节显得有点喧宾夺主了,所以就单独写一篇文章吧。

前置知识

只需要基础的计网知识,以及初步了解Nftables(起码能看懂规则)。

另外丢掉那张老旧的(you know what)Netfilter数据包流向图吧,由于Nftables的链是可以自由添加、自定义优先级的,现在它应该是这样1

Netfilter 数据包流向图
Netfilter 数据包流向图

思路

要达到的效果是这样的:

  • DHCP服务器将默认DNS服务器指向本机的Telescope DNS,由其负责解析2,直连DoH
  • 局域网内通信不经过代理工具,包括DHCP等特殊服务
  • 其他来自局域网内的TCP/UDP流量都经过代理工具,由代理工具分流
  • 不代理路由本身流量,暂时没需求

也就是说流量会经过Nftables和代理工具两次分流。对于较为固定、不高于三层的分流规则,交给Nftables效率更高;而对于按站点分流之类的规则,则交给代理工具,更灵活。

对于第一条,局域网内机器发送数据包的路径为:

  1. 局域网内客户端发出DNS请求
  2. Nftables prerouting 对目的为局域网内的请求进行直连,不代理
  3. Telescope DNS接收到请求(此处设未缓存)
  4. Telescope DNS向上游发出请求,不带任何meta mark
  5. Nftables postrouting 进行NAT
  6. 远程DNS服务器收到请求,返回结果
  7. Nftables prerouting 对来自局域网外的请求直连
  8. Telescope DNS收到上游回复
  9. Telescope DNS将结果返回给客户端
  10. Nftables postrouting 进行NAT
  11. 客户端收到回复

对内网的包:

  1. 局域网内客户端发出请求
  2. Nftables prerouting 对目的为局域网内的请求进行直连,不代理
  3. 进入 forward 链,转发给目标机器 或/同时被本机处理

对分流决定代理的正常TCP/UDP流量:

  1. 局域网内客户端发出请求
  2. Nftables prerouting 对于来自局域网、目标为外网的请求,进行 tproxy
  3. 代理工具接收并封装请求,并发送到代理服务器,带有meta mark 2
  4. Nftables postrouting 进行NAT
  5. 代理服务器处理代理请求,返回结果
  6. Nftables prerouting 对于来自外网的请求,不代理(直接发往代理工具)
  7. 代理工具接收并解封装请求,将结果返回给客户端
  8. Nftables postrouting 进行NAT
  9. 客户端收到回复

对于决定不分流的正常TCP/UDP流量,其实也和上面相似,只不过代理工具直接将请求发往目标服务器,目标服务器也直接把结果返回给代理工具。

实现

类似于 Xray TProxy 透明代理 之类的本机透明代理方案很多,一般也适用于旁路由。核心的规则其实就两条,也就是在 prerouting 的链上加入(tproxy 仅支持 prerouting):

perl
1
2
ip protocol tcp tproxy to 127.0.0.1:12345 meta mark set 1
ip protocol udp tproxy to 127.0.0.1:12345 meta mark set 1

然而就以上面链接中这篇教程的配置为例,它包含如下三行规则,通过跳过目的为本地IP的代理,同时避免了远程服务器发回的流量被重新代理局域网内主机间流量被代理的问题:

perl
1
2
3
ip daddr $RESERVED_IP return
ip daddr 192.168.0.0/16 tcp dport != 53 return
ip daddr 192.168.0.0/16 udp dport != 53 return

第二个问题很好理解。而这些规则能解决第一个问题,主要原因在于主路由已经做好了NAT,看起来已经是从远程主机IP发往代理客户端主机IP的流量了,也就是有了目的IP在上述范围内的特征。同样的方法却不能套到主路由上,因为NAT一般在 postrouting 阶段去做,而 tproxy 一般在 prerouting 阶段进行3。此时,网络层的目的IP仍然是本机的外网IP,而非192.168.0.1这样的局域网IP。

然而,出于某些原因,我们并不能在 postrouting 阶段等待NAT完成后再更改目的IP4,也就是说这个阶段已经不能把数据包拐到代理上面了。考虑到 tproxy 的作用阶段,实际上在 prerouting 中同时进行Nftables阶段的分流和 tproxy 更为合适。相应的,为了识别远程服务器发回流量,我们要添加一条规则,也就是源IP不在内网IP段中,则不代理。

 

另外不管是上面的教程还是Linux kernel文档3,都指出 tproxy 应搭配自定义IP route使用,以让透明代理的包正确地传输到本地。在进行透明代理的同时为数据包附上meta mark 1,这样就可以被以下两条命令定义的路由规则捕获,然后被传输到本地:

bash
1
2
ip rule add fwmark 1 lookup 100 # 对于带标记 1 的包,使用编号为 100 的路由表(100 没有什么特殊含义)
ip route add local 0.0.0.0/0 dev lo table 100 # 对于路由表 100,经过 lo 设备发送到 0.0.0.0

实验表明去掉这两条规则后,无法正确进行透明代理。由于 tproxy 并不会修改数据包本身的内容,所以我猜测该数据包可能会按照默认策略路由,不会进入本地回环为代理工具所接收。

 

接下来就该配置代理软件了。我个人建议使用Xray-core,需要添加入站:

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
  "inbounds": [
    {
      "tag": "all-in",
      "port": 12345,
      "listen": "127.0.0.1",
      "protocol": "dokodemo-door",
      "settings": {
        "network": "tcp,udp",
        "followRedirect": true
      },
      "sniffing": {
        "enabled": true,
        "destOverride": [
          "http",
          "tls"
        ]
      },
      "streamSettings": {
        "sockopt": {
          "tproxy": "tproxy"
        }
      }
    },
  ]
}

并且建议为每个出站都设定meta mark不为1:

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "outbounds": [
    {
      "streamSettings":{        
        "sockopt": {
          "mark": 2
        },
      },
    },
  ]
}

 

在添加了上面的规则后,发现连不上新设备了,查找DHCP服务器的日志也并未发现端倪。发现由于DHCP的Discover目标为广播IP,并未被排除代理,所以被代理工具截留。我使用的workaround为添加一条规则,对目标为udp/67端口的数据包不代理,DHCP恢复正常。

宿舍的网络竟然有外界可以直接访问的公网IP(震惊),所以个人还加了 filter 表,用于防止外界白嫖代理。这个看一下下面的配置文件就明白,我不多讲。

配置

如果你希望使用此配置文件,请根据你路由器的接口分配、网段分配、代理工具等情况进行修改。另外也不要忘了添加 ip 路由规则。

perl
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#!/usr/sbin/nft -f

flush ruleset

define RESERVED_IP = {
    192.168.0.0/24,
    0.0.0.0/8,
}

define WANLINK = lan0
define LANLINK = eth0

table ip nat {
    chain postrouting {
        type nat hook postrouting priority 100; policy accept;
        oifname $WANLINK masquerade
    }
}

table ip filter {
    chain input {
        type filter hook input priority 0; policy accept;
        # only accept local/LAN traffic to tproxy
        ip saddr != $RESERVED_IP tcp dport 12345 drop
    }
}

table ip xray {
    chain prerouting {
        type filter hook prerouting priority mangle; policy accept;
        # source not from LAN (e.g. server reply), don't proxy
        ip saddr != $RESERVED_IP return
        # dest loopback or local traffic, don't proxy
        ip daddr $RESERVED_IP return
        # allow dhcp traffic (daddr may be 255.255.255.0)
        udp dport 67 return
        # tproxy traffic
        ip protocol {tcp,udp} tproxy to 127.0.0.1:12345 meta mark set 1
    }
}

IPv6

本节参考5

IPv6透明代理的实现与上面相似,甚至可以不用建新表,而是修改 xray 表即可。

IPv6对应的路径设置如下:

bash
1
2
ip -6 rule add fwmark 1 table 106
ip -6 route add local ::/0 dev lo table 106

这里我直接将修改后的 xray 表列在下面:

perl
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
table inet xray {
    chain prerouting {
        type filter hook prerouting priority filter; policy accept;
        ip daddr { 127.0.0.0/8, 224.0.0.0/4, 255.255.255.255 } return
        ip6 daddr 2408::/16 return
        meta l4proto tcp ip daddr 192.168.0.0/16 return
        ip daddr 192.168.0.0/16 udp dport != 53 return
        ip6 daddr { ::1, fe80::/10 } return
        meta l4proto tcp ip6 daddr fd00::/8 return
        ip6 daddr fd00::/8 udp dport != 53 return

        # tailscale
        udp sport 41641 return
        udp dport 41641 return
        udp dport 3478 return
        udp sport 3478 return

        meta mark 2 return
        meta l4proto { tcp, udp } meta mark set 1 tproxy ip to 127.0.0.1:12345 accept
        meta l4proto { tcp, udp } meta mark set 1 tproxy ip6 to [::1]:12345 accept
    }

    chain divert {
        type filter hook prerouting priority mangle; policy accept;
        meta l4proto tcp socket transparent 1 meta mark set 1 accept
    }
}

如果你的代理工具只监听了IPv4地址,那么需要同时监听IPv6 [::1]:12345。

优化

Nftables数据包追踪

在编写Nftables配置的时候,经常可能有疑问,这个数据包到哪里去了。可以使用 nft monitor trace 监视特定数据包的流向和规则判定过程,详细使用方法见 6

米家设备无法连网

如果开启透明代理的情况下,发现米家设备无法联网(或者无法绑定到App),而关闭透明代理又恢复,那么可能是因为米家设备需要连接域名 mijia cloud(是的你没看错,中间一个空格),代理工具嗅探到了这个域名并尝试解析以分流,但其无法正常处理7,于是走默认。可以通过查看代理工具的日志来验证这个情况,Xray-core和Clash都可能会有这个问题。

解决方法是跳过这个域名的嗅探分流,如Xray-core加上这一段:

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "inbounds": [
    {
      "sniffing": {
        "domainsExcluded": [
          "mijia cloud"
        ]
      }
    }
  ]
}

干出这个事的小米工程师真该拉出去祭天。

境内流量完全直连

上述的配置会令境内外流量均经过代理工具,最直接的问题就是访问速度变慢。但可能也有些其他后果。

曾遇到一个非常玄学的问题,如国服原神“连接超时”,国服星穹铁道错误“1001_1”等。即使设置了正确的分流规则和DNS解析,也可能无法登录。抓包发现有许多和米哈游的TCP连接被reset掉,暂时不知道根本原因。

个人的解决办法是使用Nftables进行国内IP分流,具体可见 这篇文章。实测使用该措施后,由于不需要回到用户态过代理软件,日常上网也会快一点。不过若依赖于特定代理软件的附加功能,如Xray-core的Full Cone NAT,则对于绕过的流量,也会一并失去这些功能。

此处附一个适用于IPv4和IPv6的转换脚本,将APNIC的数据转换为nftables格式,并输出到标准输出:

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import re

# Function to calculate subnet mask from number of IPs
def get_cidr_from_size(size):
    import math
    return 32 - int(math.log2(size))

def parse_ip_ranges(file_path):
    v4_list = []
    v6_list = []

    # Regular expressions for extracting IPv4 and IPv6 ranges
    ipv4_regex = r'apnic\|CN\|ipv4\|([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)\|(\d+)\|'
    ipv6_regex = r'apnic\|CN\|ipv6\|([0-9a-fA-F:]+)\|(\d+)\|'

    with open(file_path, 'r') as file:
        for line in file:
            # Check for IPv4
            match_v4 = re.search(ipv4_regex, line)
            if match_v4:
                ip = match_v4.group(1)
                size = int(match_v4.group(2))
                # Calculate CIDR from the size
                cidr = get_cidr_from_size(size)
                subnet = f"{ip}/{cidr},"
                v4_list.append(subnet)

            # Check for IPv6
            match_v6 = re.search(ipv6_regex, line)
            if match_v6:
                ip = match_v6.group(1)
                prefix_length = int(match_v6.group(2))
                # Construct the subnet
                subnet = f"{ip}/{prefix_length},"
                v6_list.append(subnet)

    return v4_list, v6_list


def generate_nftables_rule(file_path):
    v4_list, v6_list = parse_ip_ranges(file_path)

    # Format the output in nftables style
    v4_rules = "define chnroute_list_v4 = {\n" + "\n    ".join(v4_list) + "\n}"
    v6_rules = "define chnroute_list_v6 = {\n" + "\n    ".join(v6_list) + "\n}"

    return v4_rules, v6_rules


if __name__ == "__main__":
    file_path = 'delegated-apnic-latest'  # Replace with your actual file path

    v4_rules, v6_rules = generate_nftables_rule(file_path)

    # Print the results
    print(v4_rules)
    print(v6_rules)

  1. https://thermalcircle.de/doku.php?id=blog:linux:nftables_packet_flow_netfilter_hooks_detail 另外这也是一篇极佳的Nftables入门文章,推荐阅读。 ↩︎

  2. 或许有人担心这会影响Xray-core的域名分流,但其域名分流是依靠sniffing头中的SNI实现的,即使Xray-core得不到解析前的域名,也不会受到影响。 ↩︎

  3. https://www.kernel.org/doc/Documentation/networking/tproxy.txt ↩︎ ↩︎

  4. https://serverfault.com/a/125913 ↩︎

  5. https://xtls.github.io/document/level-2/tproxy_ipv4_and_ipv6.html ↩︎

  6. https://wiki.nftables.org/wiki-nftables/index.php/Ruleset_debug/tracing ↩︎

  7. https://github.com/XTLS/Xray-core/issues/293 ↩︎