为什么 VPN 防火墙钩子会把同一条规则加两遍
重连可能再次运行你的钩子,所以要按期望状态设计:自有链、插入前先测试、一次串行化改动和一次范围狭窄的拆除。
钩子第一次运行正常,三次重连之后链里留下了同一条规则的三份副本。没有任何东西大声报错;脚本每次运行都只是照做被交代的事。VPN 钩子是一项可能运行多次的配置操作,因此它需要归属者、期望状态和一次串行化的改动。按这个顺序走,重复就会停止。
先给规则定好归属,再写任何一条
在写第一条规则之前,先确定这套集成归哪条链或哪张表所有。钩子自行创建并清空的专用链是最清晰的归属形式,因为清理时可以直接删除那条链,而不必碰别人的策略。如果必须往共享链里加规则,就用注释打上标记,好让之后的搜索准确找到你的那几条。归属让拆除变得安全,而拆除恰恰是会悄悄失败的环节。
iptables -N VPN_HOOK 2>/dev/null || true
iptables -C INPUT -j VPN_HOOK 2>/dev/null || iptables -I INPUT -j VPN_HOOK
iptables -S VPN_HOOK
这里的注释不是装饰。它决定了你删掉的是自己的规则,还是一条碰巧长得很像的规则,而且它会留在已保存的规则集里。把注释放进一个变量,只写一次,并在所有添加、测试或删除的命令里使用同一个字符串。
测试期望状态,而不是再次添加
写下隧道运行期间应当存在什么:哪些接口,哪个源和目的,哪个协议和哪个端口。然后把每条规则表达成一次测试加一次插入,这样第二次运行会找到规则而什么也不做。规则存在时检查命令以零退出,而这正是脚本需要的信号。iptables 参考记录了这里用到的检查、追加和插入形式。
iptables -C VPN_HOOK -i tun0 -p tcp --dport 443 -j ACCEPT ||
iptables -A VPN_HOOK -i tun0 -p tcp --dport 443 -j ACCEPT
小心位置。检查一条规则并把它插到开头,可能把一条允许挪到原本排在前面的有意拒绝之前,于是第二次运行虽然什么都没加,却改变了行为。在自己的链里往末尾追加,并通过跳转到该链的位置来控制顺序。如果链内顺序重要,就按已知列表重建整条链,而不是一条一条地打补丁。
如果需要的规则无法表达成稳定的测试,就停下来。这通常意味着期望状态仍然模糊。
把改动串行化,而不是指望它保持住
两次钩子调用可能重叠,两者都可能在对方添加之前看到规则缺失。xtables 的等待选项会让命令等待锁,但它不能让跨两条命令的“先检查再添加”变成原子操作。用自己的锁把整个操作串行化,或者使用把声明好的批次当作单个事务应用的管理器。nftables 可以做到后者,这也是在可用主机上优先选它的原因之一。
flock /run/vpn-hook.lock /usr/local/sbin/vpn-firewall-apply
nft -f /etc/nftables/vpn-hook.nft
无论选择哪种机制,都要让临界区保持简短且可重入:中途失败的钩子应当可以安全地再次运行。记录结果,让重复运行出现在日志里,而不是从规则数量去推断。如果两个不同的守护进程都能调用钩子,先修好这一点;没有哪把锁能修复两个归属者。
只拆除属于你的那部分
拆除必须把替换连接和它送走的那条连接区分开,而且必须扛得住一次永远得不到优雅清理的事件。用添加时相同的标识去删除,绝不要为了省事而清空共享链。硬停止之后,在下次启动时跑一遍对账,而不是假定下行钩子已经运行过。
iptables -D VPN_HOOK -i tun0 -p tcp --dport 443 -j ACCEPT
iptables -F VPN_HOOK
ip -brief link show type tun
接口的生命周期也属于设计的一部分。重连可能创建出名字不同的设备,于是把第二条隧道名字写死的规则会一边看着还有效、一边已经过期。优先采用连接本身暴露的身份,并在每次运行时重新读取。把三件事分开测试:重复连接、替换连接和关机。被允许的流量应当在这三种情况下都不重复地存活,无关服务应当不受影响。
当钩子有了归属者、为它添加的每条规则都配上测试、只有一条串行化路径,并且拆除只移除它自己的状态时,这段走查就结束了。一个可以安全运行两次的钩子,比一个只聪明一次的钩子更有价值。