那些运行没有报错、却仍然报告文件缺失的钩子,通常只是看错了地方,而不是本职工作失败。脚本里的相对路径按进程的工作目录解析,而服务启动时的工作目录并不是你选的。只有当某个东西把两者固定在一起时,脚本旁边的文件和进程打开的文件才是同一个。这个缺口解释了很大一部分被误读为权限问题的钩子故障。

先检查路径,再检查权限,因为权限错误发生在错误的文件上,那仍然是错误的文件。

相对路径按进程解析,而不是按文件

当脚本调用 open("policy.json") 时,内核按进程当前的工作目录解析这个名字。脚本自身的位置完全不参与那次查找。 OpenVPN 从它自己选定的目录启动钩子,而 cd 选项可以改变那个目录,所以从自己文件夹手工测试通过的脚本,在守护进程执行时行为就不一样了。手工测试通过,服务运行却失败,日志里只提到缺失的名字。

Python 把脚本的位置暴露为 __file__,正是这一点让固定成为可能。 Path(__file__).resolve().parent / "policy.json" 从文件而不是从进程构造路径。这一行消除了对工作目录的依赖,而不改变任何权限。

有意识地固定路径,然后检查 resolve 做了什么

resolve() 会跟随符号链接,所以把钩子链接到位的安装会固定在链接目标上,而不是固定在该链接所在的目录。对版本化部署来说这通常正是想要的,而对把数据放在符号链接旁边的那种布局来说是错的。先确定部署指的是哪一种,然后照它写代码,而不是在生产里才发现差别。 Path(__file__).parent 不带 resolve() 时会保留链接自己的目录。

如果数据属于实例而不是程序,就从配置里取绝对路径。把位置硬编码会把钩子绑死在某一种主机布局上。

按顺序走过四道边界

先看工作目录:比较单元的 WorkingDirectory、OpenVPN 的 cd 选项,以及任何在 exec 之前切换目录的包装器。然后确认解析后的位置,因为另一次部署里同名的文件可能回答了查找,从而把错误藏起来。接着是文件系统视角:chroot 或服务的挂载命名空间可以让一个绝对主机路径干脆不存在。最后是访问身份,它与可见性是另一个问题。

ProtectHome 和 PrivateTmp 隐藏的是不同位置,两者都不会出现在从主机上取的目录列表里。在 shell 里对 root 存在的路径,在单元命名空间内可能完全不可见,所以服务外面测试同一条命令能证明的东西很少。

在不扩大服务范围的前提下修正查找

固定路径只解决了第一道边界。它既不会暴露服务隐藏的目录,也不会让无特权账户访问只有 root 才能读的文件。提高 script-security 改变的是 OpenVPN 会执行哪些脚本;它对相对名字在哪里解析没有任何影响。为整个服务改工作目录比问题本身宽得多,因为其他每一个相对路径——证书、日志或辅助工具——都会跟着一起移动。

优先选择窄的修正:脚本里一个确定性的路径,实例数据用显式配置值,文件归属与实际运行钩子的账户一致。

记录能指出边界的证据

一份有用的故障报告包含三样东西:钩子尝试的确切路径、产生它的环境,以及是哪一道边界拒绝了。打印解析后的路径和异常,而不要倾倒文件内容。这三样东西把错误的固定、隐藏的挂载和权限拒绝区分开来,而它们对应三种不同的修法。

“root 能从终端读到这个文件”这个说法只回答了其中一个问题,对其他问题一个都没回答。应当从服务自己的上下文里测试。

相关文章