钩子脚本里空的 $_ENV 数组,看起来像是 OpenVPN 什么都没传的证据。它通常证明的完全是另一件事:PHP 被配置成不填充那个超全局变量,而这些值一直待在进程环境里。第二个错误结论更隐蔽,因为连接钩子里的两个地址变量含义不同,却常被互换使用。如果从错的那个入手,两个错误都要花掉一个下午。

两个地址,两种不同的断言

外部端点就是客户端连接时服务器观察到的东西,IPv4 下是 trusted_ip,IPv6 下是 trusted_ip6。虚拟地址则是地址池为本次会话分配的地址,对应 ifconfig_pool_remote_ip。

它们回答的是不同的问题,而一条记录通常只需要其中之一,不需要两个都留。前者说明连接从哪里来,那可能是几百人共用的 NAT 地址。后者说明本次会话在隧道内所处的位置,之后它还可以被分配给另一条会话。把两者混为一谈,会产出一条看似合理、实则错误的记录。

最小化的读取大致如下。

$outer4 = getenv('trusted_ip');
$outer6 = getenv('trusted_ip6');
$inner  = getenv('ifconfig_pool_remote_ip');
$outer  = $outer4 !== false ? $outer4 : $outer6;

这两个值都不能识别一个人。一个是网络观察,另一个是租约,把任意一个当作稳定设备标识的记录,在客户端从别的网络重连、或拿到另一个池地址时,第一次就会出错。按记录必须回答的问题来选字段,然后把这个问题写在列旁边。

检查 PHP 是怎么启动的,而不是你的 shell

钩子作为 VPN 守护进程的子进程运行,环境由该守护进程构建。你的交互式 shell 是另一套环境,手动执行脚本测的是 shell 的环境,而不是钩子的环境。

先确认钩子确实在你预期的阶段触发了。挂错阶段的脚本,或者因为指令指向了别的路径而从未运行过的脚本,产生的症状与变量缺失一模一样。在脚本顶部加一行刻意的标记,在检查其他任何东西之前先确认它出现了。日志里一行带时间戳的记录,能把「从未运行」和「运行了但没有值」区分开。

然后再确认那次调用用的是哪个解释器、哪个配置文件。为 Web 服务器修改 PHP 配置,对 OpenVPN 启动的 CLI 进程毫无影响,两者甚至可能加载完全不同的 php.ini。

为什么空的 $_ENV 什么也证明不了

PHP 的 getenv 文档 描述的是直接读取进程环境,名字不存在时返回 false。而 $_ENV 这个超全局变量是单独填充的,由 variables_order 指令决定填充哪些超全局变量。

如果该指令省略了 E,那么无论环境里有什么,$_ENV 都会保持为空。于是对它取值的代码会报告一个「缺失」的变量,而这个变量其实存在,排查方向也会错误地指向 OpenVPN,而答案就在 php.ini 里。要专门检查 CLI 的配置,而不是 Web 服务器的配置。

用 getenv 读取就能避开整类混乱,而在调用解释器之前清空环境的包装脚本,会以另一种形式把它重新引入。

存关联关系,而不是更新某一行

写之前先决定记录需要哪个值。如果目的是记下会话从哪里来,就存外部端点,并标注为观察。如果目的是在连接期间把会话映射到隧道地址,就存池地址,并以会话作为键。

按已认证的身份和当前会话来更新,而不是按共享同一个隧道地址的每一行去更新,因为池地址会被重复使用,迟到的断开事件可能会覆盖较新连接的数据。要显式处理 IPv6,而不是默默只写 IPv4 的值。外部地址为 NULL 的行,好过地址写错的行,因为前者一眼就能看出不完整。

把写入绑定到 OpenVPN 提供的会话标识符上,这样重连会新建一条记录,而不是编辑旧的。使用参数化语句,并使用只有该操作所需权限的账户。钩子以守护进程的权限运行,所以一次粗心的查询比一行错误的日志影响更大。

诊断时不要倾倒环境

记录哪些名字存在、哪个阶段运行了、用的是哪个解释器。不要打印整个环境,因为钩子进程可能继承一些不该写进日志文件的值。

端到端验证一次连接:读外部端点,读被分配的地址,并把两者与服务器对该会话的看法核对。一旦这对值在某个客户端上被确认,这个关联在别处也可以信任。

相关文章