OpenVPN 在 shell 里能跑,在 cron 下却失败:问题出在 PATH
在 shell 里能连上的命令,可能在隧道启动前就在 cron 或 subprocess 下失败。修好路径、工作目录和终端。
同一条命令在 shell 里能连上,到了 cron 下却直接死掉,于是很自然地怀疑隧道或证书有问题。在相信这个结论之前,先看两次运行有什么共同点:二进制、配置和凭据完全一样,变的只有进程环境。当程序根本找不到时,再怎么调 TLS 都没用。修复通常落在一条路径、一个工作目录和几个环境变量上,而不是 VPN 本身。
在能用的 shell 里执行 command -v openvpn 会打印出答案,而它往往就是 /usr/sbin/openvpn,一个 cron 不会放进搜索路径的目录。
隧道从未启动,所以隧道不是问题
cron 启动的任务继承的是最小环境,按惯例只有 PATH=/usr/bin:/bin,而且没有控制终端。装在 /usr/sbin 或 /usr/local/sbin 里的东西根本不在这个列表中,日志行读到的就是 openvpn: not found,而不是 TLS 错误。Python 的 subprocess 失败方式不同:它继承解释器进程的环境,可能比 cron 的更丰富,但仍然没有能展开波浪号的 shell。这两种失败都发生在任何一个数据包离开主机之前。
快速区分两者的办法是读第一条错误,而不是最后一条。缺二进制会抛出 shell 或 Python 异常,配置错误会给出 OpenVPN 的用法提示,真正的握手失败则会给出时间戳和对端地址。只有第三种才是隧道问题。
绝对路径和参数列表胜过 shell 字符串
显式传入解析后的路径,完全绕开 shell。subprocess.run(["/usr/sbin/openvpn", "--config", config, "--verb", "4"], capture_output=True, text=True, timeout=90) 会给你返回码、stdout 和 stderr,不必让 shell 去解释文件名。用 os.environ["HOME"] 或 pathlib.Path.home() 拼出配置路径,这样波浪号永远不需要展开。确实需要 shell 语法时用 shlex.split,因为用 .split() 切分字符串,遇到含空格的路径就会出错。也要读 stderr,OpenVPN 把大多数早期失败都写在那里。
同样的规则也适用于 cron:任务行不是登录 shell,所以没有东西会加载你的配置文件,也没有东西会定义你的别名。
crontab 到底继承了哪些东西
crontab 条目拿到的是 crontab 声明的环境加上几个继承来的默认值,所以要么把路径写全,要么把调用包进一个会这么做的脚本。文件顶部的 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 这类行覆盖了常见的安装位置,而用 >> cron.log 2>&1 做重定向,才能让下一次失败可读。用口令保护的配置文件在 cron 下没有办法询问自己的密码,因为根本没有终端可以提问。这种连接应该放在能读取凭据文件的 systemd 单元里,而不是放在假装可交互的任务里。
相对路径是第二个陷阱。测试运行常常发生在存放配置文件的文件夹里,而 cron 任务从完全不同的地方启动,于是 --config office.ovpn 解析到一个并不存在的文件,报错读起来像文件缺失,而不是隧道缺失。
剥离环境来证明它
直接复现最小环境:env -i PATH=/usr/bin:/bin /usr/sbin/openvpn --config office.ovpn --verb 4。如果这次运行抱怨路径或终端,问题就在 VPN 之上。如果它能连上,剩下的嫌疑就是它运行所用的账户、它启动时所在的目录,以及它出示的凭据。两种结果都比再做一轮证书检查强。
然后让包装器自己报告。为每个到达的阶段返回不同的退出状态,把两个流都带上时间戳记进日志,下一次失败就会告诉你它死在路径解析、认证,还是隧道已经起来之后。
单元还需要正确的顺序:Wants=network-online.target 加上 After=network-online.target 能防止客户端在地址或默认路由出现之前启动,否则就会表现为手动重启时消失的证书或 TLS 错误。