不要把 systemd 加固叠加在 OpenVPN 的 user 降权之上
两套最小权限配方合在一起并不等于更严格的一套。先决定由谁完成身份切换,再把权限映射到整个生命周期。
两套最小权限配方粘贴在一起,并不会得到更严格的部署。一种模型让守护进程以特权启动,初始化之后再降到非特权账户;另一种由服务管理器从第一条指令起就以受限身份启动。两者各自都能工作,但合在一起往往得到一个两项工作都做不干净的服务。下文按顺序走一遍这个决定。
两种模型,切换只有一个主人
第一种模型属于守护进程。社区版 OpenVPN 的 user 与 group 选项会在隧道和密钥建立之后切换身份,而 persist-key 加上 persist-tun 则让这些资源此后依然可用。
第二种模型属于服务管理器。systemd 的 User= 或 Group= 设置决定进程以什么身份启动,能力指令决定它可以持有什么。在这两种模型之间做选择,首先是打包问题,其次才是安全问题,而软件包的维护者已经给出了答案。你要做的是找到那个答案,而不是发明第三种方案。
两者都正当。错误在于把它们当成同一套设计的分层,因为第二种模型可能让第一种的身份切换变成进程不再被允许执行的操作。
先决定身份在哪里切换
如果单元本来就以专用账户启动,再让守护进程去切换到另一个账户,就是在要求一次被加固的服务根本不该做的 setuid 转换。故障表现为启动时的权限错误,而不是清晰的政策提示。 它读起来像缺少某项能力,于是人们转向更宽的边界集合,而不是去问身份问题。
在动手修改之前,先读清已安装的软件包实际提供了什么,包括它的 drop-in 目录。有些单元故意省去守护进程自身的降权,因为服务管理器已经处理;另一些则依赖这些选项完成初始化之后的切换。分不清继承的是哪种模型就删掉其中一项,正是一个原本能用的服务停止启动的原因。
把选定的模型写成一行:归管理器所有,还是归守护进程所有。之后的每一条指令都必须与这句话一致,混合的单元应当视为缺陷而非折中。
能力是上限,不是授予
能力边界集合说明进程最多能持有什么。它是一道天花板,在其中列出某项能力本身并不授予任何东西,因此边界集合宽松的单元仍可能无法建立隧道。
真正把一项能力放入服务环境集合的是 AmbientCapabilities=。 systemd 执行手册 分别记录这两条指令,而这个区别解释了网上大多数令人困惑的能力建议。
只有当失败的操作明确指出了某项能力之后,才去添加它。更宽的边界集合会掩盖真正的原因,让服务比设计预想的更有权。它也让单元更难审查,因为天花板不再描述这份工作。真正的问题修好之后,就把多出来的能力去掉。
把权限映射到整个生命周期
列出进程在每个阶段必须做什么:启动、稳态、重新认证与拆除。创建接口、修改路由、读取受保护的密钥、调用辅助程序,并不都发生在同一时刻,也不都受同样的要求约束。先按阶段排序,再按能力排序。
持久化选项在重新协商之间保留资源,它们并不会授予从未拥有过的权限。进程在启动时读不到的密钥,之后照样读不到,无论持久化标志怎么说。标志保留的是本来就已经能用的东西。
使用专用账户,而不是共享账户。OpenVPN 上游项目警告不要习惯性地拿 nobody 当通用示例,因为主机上的其他服务可能已经以该账户运行,你以为存在的隔离并不存在。
改变本地工作负载的特性,例如数据通道卸载或链接方式不同的构建,会改变需要哪些操作。软件包变化时就要重看这张地图,而不是只在出问题时才看。
把计划写下来,并保留恢复通道
部署记录应当写明管理器、启动身份、预期的稳态身份,以及每一项授权和需要它的操作。正是这份记录让下一次变更可以审查,而不是做实验。
编辑期间保持控制台可用,因为启动失败的单元很难远程修复。在宣布变更完成之前,测试一次重启和一次完整重启。能扛过重启的单元,仍可能在网络就绪之前失败。
成功是一套被理解的最小权限生命周期,而不是一个碰巧以非 root 名字运行的进程。