当 client-connect 脚本以非零码退出时,OpenVPN 会拒绝该客户端,而运维通常以为对应的 client-disconnect 脚本会收拾干净。它往往根本不会执行,因为 disconnect 的处理取决于 connect 阶段完成了什么。把拒绝与回滚当成同一个机制,正是让一次失败的登录变成泄漏的防火墙规则或孤儿记录的原因。这是两项彼此独立的义务,而只有一项是自动的。在责怪服务器留下状态之前,先读一读 hook 自己的退出路径。

非零退出会拒绝客户端,却不会撤销你做的工作

client-connect 的退出状态是一项准入决定。零表示放行,其他任何值表示在实例完全建立之前就把它拆掉。这份契约里没有任何一处会检查脚本对外部世界已经做了什么。如果脚本添加了 iptables 规则、写了一行记录或递增了计数器,这些改动在拒绝之后依然存在。

同样的不对称也适用于插件:一个实例可能执行多个 connect 函数。后来的失败不会追溯取消先前的成功,而先前的函数也无从得知兄弟函数已经失败。

拒绝是自动的,清理不是

OpenVPN 2.6 手册把 client-disconnect 的处理绑定在先前的 connect 处理已成功这一条件上。一处有文档记载的例外覆盖级联链路:如果某个 connect 步骤已经返回成功,那么即使后续 connect 步骤失败,拆除实例时对应的 disconnect 步骤仍会执行。这个例外很窄,且取决于配置了多少个回调,所以测试一个 shell 脚本对链路毫无说明力。

在设计之前先把实际配置摸清楚:哪个脚本、哪个插件、以什么顺序执行。下表列出半途而废的配置会留下什么。

What the hook did before failingWhat the client seesWhat stays behind
Created a firewall entryA rejection, with no tunnelA rule matching an address nobody owns
Wrote a state rowA rejection, with no tunnelA record of a session that never started
Allocated a tunnel addressA rejection, with no tunnelAn address marked busy until something releases it

每一行对客户端而言结局相同,对主机而言各不相同。客户端无法分辨发生了哪一种,所以 hook 必须在返回失败状态之前把自己做过的事撤回。

在失败的脚本内部做补偿

在触碰共享状态之前,能校验的都校验:证书字段、地址可用性、客户端所属的组。最便宜的拒绝发生在任何分配之前。一旦配置跨越多个外部操作,就要记录哪些已经完成,并在返回失败状态之前按相反顺序撤回。

一个具体例子:脚本创建了每客户端防火墙条目,随后写入描述它的状态行时失败。失败分支应当删掉刚创建的条目,然后以非零码退出。等待一个可能永远不会被调度的 disconnect 事件,正是规则比它本应服务的会话活得更久的原因。

让补偿幂等且有归属

清理必须可以安全地执行两次,并且精确限定它能删除什么。来自早先尝试的迟到事件,不应仅仅因为两者共用同一个名字或被回收的隧道地址,就删掉较新会话的分配。把具体的句柄或操作 id 与资源一起记录下来,并按该键删除,而不是按名字删除。数据库事务只对数据库自身的改动有帮助;边界之外的防火墙规则需要显式的补偿步骤。

假设进程可能在改动与清理之间死掉,并增加一个对账器,按计划把外部状态与已准入客户端列表做比对。

测试三条真正不同的路径

演练三种情况:尚未分配任何东西就被拒绝、部分分配之后被拒绝,以及一个正常准入、稍后结束的会话。这三者产生不同的日志行和不同的残留。每一种之后都要校验外部状态,而不只是看客户端的失败消息,因为三种之中有两种,客户端失败本就是预期结果。

记录 hook 阶段、退出状态以及分配或释放了什么,但不要记录凭据。一份写明自己创建了什么资源的 hook 日志,正是半年后还能找到泄漏规则的关键。

相关文章