重启是人们检验断网开关最常见的办法,也是最容易撒谎的那种检验。客户端里的那个勾选框只是一项偏好;真正决定成败的,是网络栈起来到隧道真正建立之间,有没有东西在强制执行封锁。在 Windows 上,那个东西通常是 Windows Filtering Platform 里的一条筛选器;在 Linux 上,是一个单元加载的一套规则;在 macOS 上,是一个 pf 锚点;在 Android 上,是系统自带的常开与锁定标志。这些安排在一次重启里的表现差异极大,而只有平台级的那种,才赶在任何人登录之前就位。

一次重启其实提出了三个彼此独立的问题

把问题拆开。第一,策略是否以某种启动时会被读取的形式存在于磁盘上?第二,那个读取者是否在任何流量出去之前就跑起来了?第三,策略是否覆盖了你真正在意的流量,也就是每个进程、两个地址族和 DNS。对第一个答“是”、对第二个答“否”,正好产生人们报的那种断网开关挺不过重启的故障:启动那几秒钟流量漏了出去,而没人发现,直到有人去查。

把层分开看。一份被保存的防火墙规则集、一个开机启动的服务、一项客户端偏好,是三个寿命完全不同的对象。偏好能挺过重启,因为它是配置文件里的一个值。规则才是唯一真正拦住数据包的东西,而 它们会跟着持有它们的内核一起消失。

找出真正恢复封锁的那个对象

在 Linux 上,netfilter 规则活在核心里。除非你专门做了安排,否则开机时没有任何东西把它们写回去:iptables-save 加上 netfilter-persistent,nftables.service 会加载 /etc/nftables.conf,或者一个运行你自己脚本的 systemd 单元。去查实际启用了什么,而不是凭记忆里配置过什么。

systemctl is-enabled nftables netfilter-persistent 2>/dev/null
sudo nft list ruleset
sudo iptables -S OUTPUT

开机几秒后规则集是空的,这就是标题问题的答案。在 Windows 上,先问清客户端服务是以什么方式启动的,然后在隧道未建立时把平台筛选器的状态导出来。

Get-Service | Where-Object { $_.Name -match 'vpn' } | Select-Object Name, Status, StartType
netsh wfp show state

第二条命令把 wfpstate.xml 写到当前目录。去找那些在客户端连接之前就已经存在、动作为阻止的筛选器。Base Filtering Engine 开机很早就启动,并会恢复带持久标志添加的筛选器,所以有些断网开关在客户端窗口还没出现时就已经在拦流量,有些则不会。在 macOS 上,sudo pfctl -s info 会报告 pf 是否启用,sudo pfctl -s rules 会显示加载了什么,而 VPN 客户端通常把规则挂到锚点上,而不是主规则集。一条点名 utun3 的规则,在下次开机后可能指向别的接口,因为那个编号是运行时分配的。

盯住网络起来了和隧道起来了之间的空档

危险的时间段不是整个重启,而是那个空档。接口在某一刻拿到租约和默认路由,VPN 服务在另一刻启动,而隧道完成认证还要更晚,有时甚至要等用户登录、凭据代理跑完。如果封锁是最后才施加的,空档里的一切都是明文出去的。

在 systemd 主机上,别猜顺序,去读顺序。对 VPN 和防火墙单元执行 systemctl cat,看看它们是 After= 还是 Before= 于 network-pre.target 和 network-online.target。排在 network-online.target 之后的防火墙单元会输给接口;被 network-pre.target 要求、且排在它之前的那个才会赢。在 Windows 上,对应的问题是服务的启动类型和它的依赖,因为设成 Manual 的服务根本没法拦住开机阶段的过程。如果你的要求还包括登录前的流量,那就只有设备级的安排才满足:Always On VPN 设备隧道用机器证书认证,在用户登录之前就连接,而 Microsoft 把设备隧道的配置单独记录下来,与用户隧道分开。Android 走的是另一条路,由系统强制常开 VPN,并在系统层面而不是应用进程层面拦住没有 VPN 的连接。

跑一遍测试,再对比两条时间线

用一个泄了也不心疼的目标重启,并且要在空档里取证,而不是事后。最干净的办法是开机探针:一个 oneshot 单元或计划任务,尽早启动,记录默认路由和防火墙状态,用一个短超时试一次请求,再往本地文件里追加一行。把这份记录和 journalctl -b -u 或系统日志里 VPN 服务的启动时间对比。

然后再跑常规测试:隧道起来,断掉连接,看直连流量是否被拦住,再恢复。第二个过、第一个不过,才是正常结果,这说明封锁是在客户端这一侧,在客户端连接时才施加,而不是开机时。我最后审的那台机器,封锁来自 NetworkManager 的一个 dispatcher 脚本:它在,它正确,它在接口起来之前那几秒里毫无用处。把两个结果都记下来,因为挺过重启这句话只有在你说明白是哪一层、从哪一刻起挺住了之后,才有意义。

把空档补上,但别把自己的退路剪断

如果开机时没有封锁,就把执行往下挪一层,而不是再加一条客户端规则。把规则集存好,交给一个排在网络可用之前的单元去加载,并确认那个单元不需要一次 DNS 查询、也不需要一次网络挂载,那些正是它要保护的东西。覆盖面和时间点一样重要:只按用户 ID 或 cgroup 匹配的规则会把每个系统守护进程放在封锁之外,而一套只处理 IPv4 的规则会让 IPv6 和 DNS 仍然可达。

给自己留一条退路。一台在开机时封死一切、随后又认证不上的机器,等于完全没有网络,而修法不是删掉封锁,而是把凭据或顺序修好。为本地控制台保留一份写下来的流程,并为隧道真正需要的东西留一个范围很窄、且刻意为之的例外,比如服务器地址,以及隧道存在之前所用的解析器。

在信任这套安排之前,按顺序确认四件事。策略存在开机读得到的地方。那个读取者在接口可用之前就跑起来。封锁覆盖两个地址族和 DNS。封锁生效时 VPN 仍然能完成认证。四条都成立时,重启什么也改变不了。

相关文章