一个国家要用的四十个 .ovpn 文件从服务商那里送来,方案看上去理所当然:随便复制一个到位置上,然后启动隧道。可一旦写成 ExecStart 里的管道,这个方案就会在开机时死掉,留下的状态码什么也说明不了。单元文件不是脚本,修法只有两种:显式引入一个 shell,或者干脆不再挑文件。

症状:单元刚启动就立刻退出

状态显示服务在不到一秒里就失败了,而日志那行点名的命令,任何 shell 都不可能收到。关于隧道的记录一条都没有,因为隧道根本没开始建立。

从日志里读回确切的命令行,是确认这一点最快的办法。systemd 会原样回显它执行的内容,于是这串里管道符号会以字面参数的身份出现,而不是运算符。

为什么 ExecStart 跑不了管道

systemd 自己切分命令行,套用它自己的引号规则和变量替换,然后直接执行结果。管道、重定向、shell 的 glob 展开和命令替换,压根不属于这套语法。这是设计选择而非疏漏:单元声明式地说明要跑什么,shell 逻辑属于单元所调用的脚本。两种修法都由这一点推出,而且都不需要给单元更多权限。

这也解释了那个误导人的退出码。第一个参数被当成可执行文件,所以畸形的那一行在你自己任何逻辑跑起来之前就已经失败。

在 ExecStartPre 里做选择

最直接的修法是加一个准备步骤,显式调用 shell,并把选择与启动留在同一个单元里,这样一个失败就不会让另一个独自跑着。

[Service]
Type=notify
ExecStartPre=/bin/bash -c "ls /etc/openvpn/profiles/au* | shuf -n 1 | xargs -r -I{} cp -f {} /etc/openvpn/%i.conf"
ExecStart=/usr/sbin/openvpn --config /etc/openvpn/%i.conf

三个细节决定这套做法能不能扛住真机器。第一个坑是命名:systemd 在解析单元时就替换 %i,远早于 bash 运行,所以这个说明符必须放在单引号外面。xargs -r 防止 glob 什么都没匹配到时 cp 不带参数就执行。而准备步骤失败会停掉单元,这正是对的结果:用昨天的配置启动,比不启动更糟。随机选择总是挑到同一个文件,通常不是运气差。检查一下被复制的文件是不是被软件包更新覆盖了,或者 glob 是不是只匹配到一条陈旧条目。

或者让 OpenVPN 自己挑端点

OpenVPN 已经能在一个配置里放多个端点并在其中随机选择,这样 shell、复制和写权限都被整个从启动路径里拿掉了。

client
remote-random
<connection>
remote au1.example.net 1194 udp
</connection>
<connection>
remote au2.example.net 1194 udp
</connection>

这种写法把服务器选择留在服务商写好的文件里,于是改名式的轮换不会弄坏单元。它还让候选列表可审计,而针对目录的 glob 从来做不到这一点。当各配置的差别不止一个端点时,打乱仍然值得保留:证书不同、路由指令不同、加密策略不同。那种情况下要选的是整套配置,用 shell 步骤来表达才诚实。

回读实际用到的实例和文件

在调试内容之前,先确认到底在跑哪个单元。打包的 meta 服务和模板属于基础设施;与你文件对应的单元,是以文件名命名的那些实例。

systemctl list-units 'openvpn*'
systemctl cat [email protected]
journalctl -u openvpn@au -n 30 --no-pager

一个没人记得启用过的实例,通常会追溯到软件包目录里一个来路不明的配置文件。关掉的应该是那个具名实例而不是模板,否则下次软件包升级又会把它带回来。改完之后启动单元,读回日志报告的命令行,再确认它实际打开的配置路径。就这一项检查,能把正常工作的随机选择和悄悄复用上个月文件的服务区分开。

相关文章