当你以 root 手动运行 hook 时它工作正常,而服务一启动它就返回Operation not permitted。两次运行之间文件没有变化,环境变了。systemd 给进程的是一个更小的世界:能力更少、不同的/dev、只读的文件系统路径和任务上限。把失败看作拒绝了一次具体操作的边界,而不是脚本坏了。

从被拒绝的操作开始

一行说 unit 失败了的日志几乎不能告诉你什么。有用的那行会点出被拒绝的调用:打不开的设备节点、写不进的目录、创建不了的 namespace。在动任何一条指令之前先找到那个调用,因为每条指令对应不同的限制。然后读实际运行过的 unit(包括 drop-in),而不是软件包附带的文件。

下面的查询会打印 fragment 路径、drop-in 目录,以及对设备或文件系统拒绝有影响的属性。在判断哪条边界相关之前,先把它们都读完:

systemctl show "[email protected]" -p FragmentPath -p DropInPaths -p PrivateDevices -p DevicePolicy -p DeviceAllow -p ProtectHome -p ProtectSystem -p TasksMax
journalctl -u "[email protected]" -b --no-pager | grep -iE 'operation not permitted|permission denied|cannot open|failed'

那个代码块里没有任何写操作。如果日志里没有那条被拒绝的调用,就为一次运行提高客户端日志级别,而不是猜指令。进程在运行只能证明 unit 启动了,不能证明 hook 以所需权限运行过。

namespace 需要 TUN 设备从不使用的 mount 权限

同一个 hook 里两个看起来相似的操作有不同的权限配置。打开/dev/net/tun并创建接口需要网络管理权限以及访问该节点。创建命名网络 namespace 还需要与 mount 相关的工作,因为 namespace 工具会挂载一个新的/sys视图,并把状态目录绑定到位。 上游客户端 unit 故意把 mount 管理排除在其能力边界之外,所以 namespace 这一步被拒绝,而隧道本身本来可以工作。

这就是为什么授予设备节点救不了这个脚本。设备许可是在回答一个问题:这个进程能否打开这个节点,而 namespace 是另一个问题。在放宽任何东西之前,先把失败调用和它需要的能力对上。

你在 shell 里做的测试会继承不受限制的 root 拥有的每一项能力,而 unit 继承的是其能力边界保留的部分。 两次运行是不同的实验 即使二进制完全相同。

设备、受保护路径与任务上限

一旦知道被拒绝的操作,三组指令能解释大多数失败。设备访问由PrivateDevices控制,它用一组最小的伪设备替代/dev,并由DevicePolicy与DeviceAllow共同决定 cgroup 允许什么。/dev/net/tun不在那个最小集合里,所以带PrivateDevices=yes的 unit 无论设备策略怎么说都打不开它。

文件系统保护回答第二个问题。ProtectHome=yes会让/home, /root和/run/user无法访问,而ProtectSystem=full会让/usr, /boot和/etc变为只读。因此,在自身配置文件旁边写入的 hook 在你的 shell 里能工作,在 unit 下却会失败,权限形态和缺少某项能力一样。把可写输入移到策略允许的路径,或添加一条范围很窄的ReadWritePaths=条目。

资源限制是第三组,也是最不明显的。TasksMax=限制 unit 可以创建多少个任务,而文件描述符上限对 socket 和句柄的作用相同。规模超过上限的部署会以资源错误而不是权限错误失败,这正是确切消息仍然重要的原因。

把 namespace 步骤移出 hook

需要客户端不应持有的权限的准备工作,应放在它自己的 unit 里。一个在客户端之前运行、保留结果并排在连接之前的 oneshot,可以为 namespace 步骤提供所需条件,而不用把这些权限交给隧道进程。然后客户端引用那个 unit,而不是从 up hook 调用脚本。

下面的模板只是一个起始形态,不是拿来不读就套用的东西。它把准备工作留在权限所在的位置,并不动隧道 unit:

# /etc/systemd/system/[email protected]
[Unit]
Description=Namespace preparation for %i
Before=openvpn-client@%i.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/netns-up.sh %i
CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN
[Install]
WantedBy=multi-user.target

用 drop-in 添加顺序,而不是修改打包的 unit,这样升级不会悄悄丢掉你的改动。目录以实例名命名,所以第二个配置需要自己的文件:

sudo mkdir -p /etc/systemd/system/[email protected]
sudo tee /etc/systemd/system/[email protected]/10-namespace.conf >/dev/null <<'CONF'
[Unit]
[email protected]
[email protected]
CONF
sudo systemctl daemon-reload

变更后重复同一次调用

只有同一次调用产生不同结果,修复才算确认。重启两个 unit,运行失败过的操作,并把结果与变更前保存的日志行比较。hook 再也不运行、unit 却能启动,这不叫修复。

下面的序列让比较保持诚实,因为它检查的是被拒绝的调用,而不是 unit 的颜色。把它的输出保存在变更前抓取的日志行旁边:

sudo systemctl restart "[email protected]" "[email protected]"
systemctl status "[email protected]" --no-pager
journalctl -u "[email protected]" -b --no-pager | grep -c 'Operation not permitted'
ip -brief addr show tun0

要数拒绝次数,而不是读最后一行,因为 hook 可能失败、重试然后成功,只有次数能说明边界是否移动。在 drop-in 里留一条注释,写明你放宽的指令和原因,因为下一次升级会把你的配置与打包默认值比较。只有在所有保护都被关掉后才能启动的 unit,并没有被修好。

相关文章