我是怎么上网的

如果你在南大或者中大的校园里见过我也许你会问一个问题

为什么你只需要 iPad 就能工作包括写代码)?

这个问题的答案涉及到几个方面最重要的当然还是 iPad 上面的软件生态。但是这里我想讲的是另一个方面我的网络配置。

校园网与 VPN

我首先推荐的解决方案是在宿舍或实验室里架设一台属于自己的服务器。我们所有的仓库、开发环境、影音娱乐软件等等都部署在这台服务器上。那么下一个需要解决的问题是在学校里我们有几种需要 移动办公 的场景

  • 在图书馆里自习
  • 在教室里上课
  • 在宿舍里休息

可以看到我们需要一种远程访问的手段来在学校内访问我们的服务器。理论上来说我们可以直接暴露服务器的端口然后在校园网里里直接访问。但是这样做的安全问题非常严重。校内确实一般没有人会攻击我们的服务器但是校方理论上监听了所有的流量。更重要的是例如 Jellyfin 这样的服务我们一般只会使用 http 来访问它这样的话密码以及任何其他信息都是明文传输的。

所以我推荐使用 VPN 进行访问。在需要移动办公时首先让移动设备通过 VPN 连接到服务器然后再直接访问服务器上的服务。这样的话我们的流量就会被加密校方也无法监听我们的流量。我所使用的 VPN 是基于 ipsec-vpn-server 它搭建的 ikev2 VPN 必须通过私钥证书进行连接非常安全。

我把这个服务跑在了 Docker 里面

1docker container ls | rg ipsec
2b13ca7eb8233   hwdsl2/ipsec-vpn-server   "/opt/src/run.sh"   2 months ago   Up 6 days   0.0.0.0:500->500/udp, :::500->500/udp, 0.0.0.0:4500->4500/udp, :::4500->4500/udp

可以看到它只占用了 500 4500 两个端口。这样一来你只需要在 iptables 开放这两个端口即可其他的都可以关闭。事实上我没有直接把这台服务器连接到校园网而是通过一个路由器连接到校园网服务器连接在路由器创建的内网里。我只需要在路由器的端口转发里面把这两个端口转发到服务器上就可以在校园网里访问 VPN 了。

Cloudflare Warp

中国的网络环境有一些比较复杂的问题。我们有些时候必须使用一些特殊的服务来加速国外网络的连接。我推荐的服务是 Cloudflare Warp。它是一个基于 WireGuard VPN 服务。然而这是比较 非主流 的解决方案它具有一个比较大的问题那就是无法 分流

所谓的 分流简单来说就是国内的流量不走 Warp国外的流量走 Warp. 这样的话我们可以避免一些国内的服务因为走 Warp 而变得不可用。这个特性本质上是一种用户态的路由一些 主流 的解决方案都支持这个特性。

有三种简单的解决方案来解决这个问题

  • 手动切换。这个方法最简单但是也最不方便。每次需要访问国外的服务时我们需要手动切换到 Warp. 显然这是不太合适的。
  • 使用 Warp 提供的 Split Tunnel 功能。这个功能支持对于特定的 IP 地址或域名进行分流。然而Cloudflare 没有提供任何 API 来自动化地配置这个功能。也就是说如果你想让 bilibili 服务不走 Warp你需要手工把 bilibili 的全部域名大概可能有 30 个以上一个个地用 Web 界面添加到 Split Tunnel 里面。
  • 使用混合方案开一台虚拟机让虚拟机走 Warp然后在主机里使用 主流 方案进行分流。这是比较可行的方案然而主流 的透明代理方案经常和 Docker 有冲突我也不喜欢这种方案。

这三种方案似乎都不太合理。这里看起来必须要仔细研究一下 Warp 到底是如何工作的才能找到一个更好的解决方案。

Warp 的工作原理

Warp 作为一个 VPN首先会创建一个虚拟网卡。然后所有的流量都会被发送到这个虚拟网卡上。观察到它是很简单的

1ifconfig | rg Cloudflare -A 10
2CloudflareWARP: flags=4305<UP,POINTOPOINT,RUNNING,NOARP,MULTICAST>  mtu 1280
3        inet 172.16.0.2  netmask 255.255.255.255  destination 172.16.0.2
4        inet6 fe80::bcc8:7a58:8e3a:d8d8  prefixlen 64  scopeid 0x20<link>
5        inet6 2606:4700:110:8dcf:a1af:2f13:73f3:426d  prefixlen 128  scopeid 0x0<global>
6        unspec 00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00  txqueuelen 500  (UNSPEC)
7        RX packets 2607433  bytes 790963841 (790.9 MB)
8        RX errors 0  dropped 0  overruns 0  frame 0
9        TX packets 6150386  bytes 4399939541 (4.3 GB)
10        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

一个直觉的问题是数据包是怎么被 劫持 到这个虚拟网卡上的呢

理论上来说这种工作是由路由表完成的。我们可以通过 ip route 命令来查看路由表

1ip route
2default via 192.168.1.1 dev enp34s0 proto dhcp metric 20100 
310.10.0.0/24 dev docker0 proto kernel scope link src 10.10.0.1 
410.10.1.0/24 dev br-bb1b34f83079 proto kernel scope link src 10.10.1.1 
510.10.2.0/24 dev br-cbd63cef39db proto kernel scope link src 10.10.2.1 
610.10.3.0/24 dev br-fbf2794de605 proto kernel scope link src 10.10.3.1 
710.10.4.0/24 dev br-81377bc74af2 proto kernel scope link src 10.10.4.1 
810.10.5.0/24 dev br-6dba9d07a5e4 proto kernel scope link src 10.10.5.1 
910.10.12.0/24 dev br-1e11e9d90ce2 proto kernel scope link src 10.10.12.1 
1010.10.19.0/24 dev br-d4a69960927a proto kernel scope link src 10.10.19.1 
1110.10.21.0/24 dev br-e5a33351bb28 proto kernel scope link src 10.10.21.1 
1210.10.100.0/24 dev br-9c1f570184a3 proto kernel scope link src 10.10.100.1 
13169.254.0.0/16 dev br-fbf2794de605 scope link metric 1000 
14192.168.1.0/24 dev enp34s0 proto kernel scope link src 192.168.1.185 metric 100

然而这里似乎没有任何与 Warp 相关的路由。事实上和大部分人想象的不同Linux 的路由结构还是比较复杂的。

现代的 Linux 支持一种叫做 policy routing 的机制。传统的路由算法仅仅依照目的地址进行路由判定但事实上很多时候需要通过一些其他信息源地址、端口、协议……进行路由选择所以 Linux 使用了一种 两级 的结构

  • ip route 管理的路由表进行具体的路由选择
  • 路由表可能有很多张 ip rule 根据策略规则选择使用什么路由表。路由表匹配可能失败如果失败就几乎回到 ip rule 匹配下一个可能的规则。
1ip rule
20:      from all lookup local
332765:  not from all fwmark 0x100cf lookup 65743
432766:  from all lookup main
532767:  from all lookup default

可以看到这里有四张路由表分别是 local, main, default 65743前面三张表是 Linux 在启动时自动创建的。而 65743 Warp 创建的。这条规则的意思很明确如果这个数据包没有被打上 0x100cf 标记的话那么使用 65743 路由表来处理它。自然地我们会想到Warp 的出口流量会打上 0x100cf 标记以避免回环问题Warp 的出口流量又被发送到 Warp造成无限循环

而路由表 65743 可能会非常复杂比如在我的服务器上

1ip route show table 65743
20.0.0.0/5 dev CloudflareWARP proto static scope link 
38.0.0.0/7 dev CloudflareWARP proto static scope link 
411.0.0.0/8 dev CloudflareWARP proto static scope link 
512.0.0.0/6 dev CloudflareWARP proto static scope link 
616.0.0.0/4 dev CloudflareWARP proto static scope link 
732.0.0.0/3 dev CloudflareWARP proto static scope link 
864.0.0.0/3 dev CloudflareWARP proto static scope link 
996.0.0.0/4 dev CloudflareWARP proto static scope link 
10112.0.0.0/7 dev CloudflareWARP proto static scope link 
11114.0.0.0/9 dev CloudflareWARP proto static scope link 
12114.128.0.0/10 dev CloudflareWARP proto static scope link 
13114.192.0.0/12 dev CloudflareWARP proto static scope link 
14114.208.0.0/14 dev CloudflareWARP proto static scope link 
15114.213.0.0/16 dev CloudflareWARP proto static scope link 
16114.214.0.0/15 dev CloudflareWARP proto static scope link 
17114.216.0.0/13 dev CloudflareWARP proto static scope link 
18114.224.0.0/11 dev CloudflareWARP proto static scope link 
19... (about 200 lines)

为什么会这么多规则呢

这就要说到 Split Tunnel 了。Split Tunnel 的功能是通过这些路由规则实现的。

具体来说如果默认的只有这一条所有的流量都发送到 Warp 的虚拟网卡的话

10.0.0.0/0 dev CloudflareWARP proto static scope link

现在我们添加了一条规则说1.0.0.0/8 的流量不走 Warp那么 Warp 就必须让这个路由表 分裂

10.0.0.0/8 dev CloudflareWARP proto static scope link
22.0.0.0/7 dev CloudflareWARP proto static scope link
34.0.0.0/6 dev CloudflareWARP proto static scope link
48.0.0.0/5 dev CloudflareWARP proto static scope link
516.0.0.0/4 dev CloudflareWARP proto static scope link
632.0.0.0/3 dev CloudflareWARP proto static scope link
764.0.0.0/2 dev CloudflareWARP proto static scope link
8128.0.0.0/1 dev CloudflareWARP proto static scope link

也就是说1.0.0.0/8 被排除在了这张表里所以它就不会被 Warp 所路由会被重新分配给 ip rule 的下一条规则也就是 main 表进行路由。可以看到这样的减法每次都可能产生一堆 CIDR 块。

不可行的方案

根据以上的知识一个直觉的解决方案是添加一个优先级更高的路由规则然后把大陆 IP 全部添加到这个规则指向的路由表里

1ip rule
20:      from all lookup local
332764:  from all lookup 10000
432765:  not from all fwmark 0x100cf lookup 65743
532766:  from all lookup main
632767:  from all lookup default

然而这是不可行的。第一个原因是Warp 在每次重连时会非常傲娇地覆盖我们设置的规则重连以后的情况可能是

1ip rule
20:      from all lookup local
332763:  not from all fwmark 0x100cf lookup 65743
432764:  from all lookup 10000
532765:  not from all fwmark 0x100cf lookup 65743
632766:  from all lookup main
732767:  from all lookup default

第二个原因是在不知不觉之间本机防火墙的 OUTPUT 链已经被 Warp 设下了非常强的规则。

1sudo nft list table inet cloudflare-warp
2table inet cloudflare-warp {
3        chain input {
4        ...
5        }
6
7        chain output {
8                type filter hook output priority filter; policy drop;
9                oif "lo" accept
10                oif "CloudflareWARP" goto tun
11                ip saddr 0.0.0.0 ip daddr 255.255.255.255 udp sport 68 udp dport 67 accept
12                meta nfproto ipv4 udp sport 67 udp dport 68 accept
13                ip6 saddr fe80::/10 ip6 daddr ff02::1:2 udp sport 546 udp dport 547 accept
14                ip6 saddr fe80::/10 ip6 daddr ff05::1:3 udp sport 546 udp dport 547 accept
15                ip6 saddr fe80::/10 ip6 daddr fe80::/10 udp sport 547 udp dport 546 accept
16                meta l4proto ipv6-icmp accept
17                ip daddr 162.159.137.105 tcp dport 443 accept
18                ...
19                reject
20        }
21
22        chain tun {
23                ip saddr 172.16.0.2 accept
24                ip6 saddr 2606:4700:110:8dcf:a1af:2f13:73f3:426d accept
25                ip6 saddr fe80::/10 accept
26                ip protocol tcp reject with tcp reset
27                reject
28        }
29}

这些规则的意思是只有下列三种报文可以被发出:

  • 目的地址属于 Split Tunnel 里定义的不走 Warp 的范围内
  • 目标接口是 CloudflareWARP 接口
  • 其他特殊情况比如本机的流量、dhcp 等等

结果是我们这样 绕过 的流量自然会被 reject 掉。我觉得最好还是不要和 Warp 对着干所以也不倾向于修改这些规则。值得一提的是这些规则通过通常的 iptables 命令是看不到的iptables 不支持自定义表 nftables 支持),必须通过 nft 命令来查看。

最终方案

当我们的服务器作为其他设备的 VPN 服务器的时候它本身承担了类似于路由器的功能。也就是说VPN 容器的流量其实是被 转发 出去了。

换句话说这些流量不会走 OUTPUT 因为 OUTPUT 链过滤的是本机运行的程序所发送的流量。这无疑给了我们一些暗示至少可以在 iptables 内部对 VPN 容器发出的流量做分流。

还记得之前的 0x100cf 标记吗只要这个标记被打上了流量就不会走 Warp. 那么只要在 PREROUTING 链里面给源地址在国内的流量打上标记似乎就能完成任务了例如

1iptables -D PREROUTING -t mangle -d 114.212.0.0/16 -j MARK --set-mark 0x100cf

然而国内的 IP 段实在是太多了必须写一个程序来完成这些操作。另一个问题是当你把这些 CIDR 全都加进去大概有 10000 个左右),你会发现系统的网络性能劣化到了惊人的程度。

这是因为 iptables 的匹配是纯粹的线性匹配它会一条条检查规则是否被满足这样任何的数据包都会需要 10000 次匹配这会严重影响吞吐量。

所以应该使用 ipset 创建一个 CIDR 的集合,(基于哈希表的集合匹配就相对快速了

1ipset create cn hash:net family inet
2ipset add cn 114.212.0.0/16
3...
4
5iptables -D PREROUTING -t mangle -m --match-set cn dst -j MARK --set-mark 0x100cf

我写了一个 Python 程序来执行创建和添加任务以及一个 systemd 任务使得每次开机自动执行该任务

1[Unit]
2Description=Init the network settings, will take quite long time
3After=network.target
4
5[Service]
6Type=oneshot
7RemainAfterExit=yes
8ExecStart=/usr/bin/python3 /home/ayanamists/service/route/main.py
9
10[Install]
11WantedBy=multi-user.target

虽然这里有一些 医者不能自医 的尴尬服务器本身还是没有实现分流但是这也是一个不错的解决方案了。

总结

经过以上配置我能够在校园的任何一个地方安全地访问到我的服务器并且实现了比较流畅的网络体验。我一直有个想法一个程序员单枪匹马改变世界的时代已经过去了但至少我们应该改变我们的生活用技术让生活更加美好。

参考资料