远程执行命令,为什么还要让浏览器签名?Fleetd 的命令验证与信任边界
Fleetd 为什么让浏览器签名远程命令?结合 v0.3.0 讲清 Ed25519、目标绑定、防重放、本地权限和密钥撤销,以及前端分发与网页终端的信任边界。
把机器接进后台以后,接下来很自然就会想:能不能直接在网页上执行命令?
查一下磁盘,重启一个服务,临时打开终端。这些功能做出来,管理几台服务器和软路由确实方便很多。但它也意味着,这个后台开始有能力改变其他机器。监控页面出问题和远程命令入口出问题,后果相差很大。
在上一篇里,我讲了 Fleetd 的主动连接、设备批准和身份验证。这一篇继续往下走:机器已经认识服务端,为什么收到服务端发来的命令,还要再验一次浏览器签名?
以下按 Fleetd v0.3.0 的实现来讲。签名能增加一道独立检查,但它有明确的前提,尤其是浏览器页面从哪里来,以及网页终端打开以后,输入还受不受同样的保护。
登录后台,不等于机器应该接受一切指令
常见的远程管理流程是:管理员登录,服务端确认权限,再把命令发给客户端。客户端相信服务端,收到以后执行。
这条链路容易理解,不过执行权基本集中在服务端。只要能让服务端发出一条看起来正常的消息,下游机器就可能照做。
Fleetd 在客户端多放了一道检查:这次操作是否有本机信任的签名密钥授权?
管理页面在浏览器里构造命令并签名;服务端负责转发;客户端根据自己的配置,检查签名、目标、时间和权限。登录仍然需要,客户端与服务端之间的身份验证也仍然需要。几道检查分别解决不同问题。
管理员确认目标与命令
↓
浏览器构造请求,使用本地密钥签名
↓
服务端转发签名后的内容
↓
客户端验证签名、目标、有效期、本地权限和请求 ID
↓
验证通过,才进入执行流程
因此,即便一条消息经过正确的连接送到了正确的客户端,它也不一定拥有执行资格。
签名要绑定整次操作
假设我准备在一台机器上执行 uptime。如果只给这几个字符签名,仍然会留下问题:这条命令可以发给谁?什么时候还能用?能不能再发一次?
Fleetd 签名的内容是一份完整请求,其中包含:
| 字段 | 用途 |
|---|---|
v |
协议版本 |
id |
区分这一次授权,供客户端检查重复请求 |
kind |
区分执行命令与打开终端 |
targets |
指定获得授权的目标机器指纹 |
argv 或 action |
要执行的程序与参数,或本地预设动作 |
issued_at、expires_at |
限定请求可以被接受的时间 |
timeout |
命令运行超时参数,仍受客户端上限约束 |
当前网页命令框会把输入包装成 ['sh', '-c', 输入内容]。也就是说,在页面里输入 uptime,签名覆盖的是包含这组参数在内的整份请求。页面支持管道和 &&,正是因为这里明确调用了 shell。
浏览器把请求序列化成 JSON 字节,再使用 Ed25519 签名。客户端验证的是收到的原始字节,之后才解析 JSON。
这个顺序很重要。服务端不能把签名后的 uptime 改成 reboot,也不能往目标列表里悄悄添加另一台机器,然后继续沿用原签名。内容发生变化,验证就不能通过。
如果原请求已经选择了多台机器,那么这些机器各自执行是合法的批量操作。目标绑定限制的是授权范围,不会强制每份请求只能对应一台设备。
客户端自己决定这次授权是否有效
客户端保存受信公钥列表 trusted_signers。请求里附带一个公钥,并不意味着客户端就会相信它;它必须先在本机的列表里。否则任何人都可以自己生成密钥,再自己给自己签一条命令。
检查也不止公钥和签名。客户端还要确认请求类型正确、自己的指纹在目标列表里、时间合理,以及本机权限允许这次操作。未知的 JSON 字段也会被拒绝,避免双方对请求含义产生不同理解。
前端默认生成五分钟有效的请求,客户端允许两分钟时钟偏差,并拒绝声明有效期超过十分钟的请求。这里的“五分钟”是提交授权的有效期,不是命令必须在五分钟内跑完;执行超时是另一项限制。
时钟容差也意味着,不能把它描述成“到了第五分钟就绝对拒绝”。管理端和客户端仍然需要维持合理的系统时间,不能指望签名算法补上错误的时钟。
这些检查共同回答一件事:这份来自受信密钥的授权,现在是否适用于我这台机器?
防重放,不能顺便保证任务只发生一次
签名正确的请求也可能被重复发送。比如有人保存了一份重启服务的请求,在有效期内原样再发一次。内容没有改变,单靠签名验证发现不了。
Fleetd 的浏览器会为请求生成一个随机 ID,使用 16 字节随机数。客户端在授权通过后记录这个 ID;同一份请求再次到达,就会被拒绝。
这份记录会写进状态目录下的 seen.json,默认状态目录是 /var/lib/fleetd。因此,只要状态文件正常保留,普通的客户端进程重启不会让尚有效的请求重新获得执行机会。持久化失败时,客户端也不会继续执行该请求。
但防重放和业务上的“恰好执行一次”,仍然有距离。
当前流程先记录请求 ID,再进入并发限制和进程启动阶段。可能出现 ID 已经记录,任务却因为并发已满而没有启动。另一个方向,命令也可能已经产生效果,但网络断开了,后台没有拿到最终结果。
这时重新点击执行,会生成一个新的 ID,代表一次新的授权。客户端无法凭空知道你是在补上一次失败操作,还是重复一次已经完成的操作。
所以,涉及迁移数据库、写入配置、升级软件时,仍然应该先检查实际状态,并让操作尽可能具备幂等性。备份恢复时也要留意状态目录:防重放记录可以抵抗正常进程重启,不等于能抵抗任意磁盘回滚或状态丢失。
有可信签名,也要受本地权限约束
Fleetd 把机器权限分为三个级别:
| 权限 | 客户端接受什么 |
|---|---|
monitor |
监控;拒绝远程命令和终端 |
allowlist |
经过可信签名的本地预设动作 |
full |
经过可信签名的任意命令和终端请求 |
刚接入、只需要看状态的机器,保留 monitor 就够了。确实需要少量维护动作时,可以考虑 allowlist。
例如,在一台使用 systemd 且安装了 nginx 的机器上,可以配置一个只查询服务状态的动作:
permission: allowlist
allowlist:
nginx-status: [systemctl, is-active, nginx]
这只是权限相关的配置片段,完整配置里还需要受信公钥和连接等设置。
执行这个动作的请求携带 action: nginx-status,实际程序和参数由客户端从本地配置中取出。远端不能在这个动作后面追加参数,把查询变成其他操作;请求也不能同时带上 action 和 argv。
不过,v0.3.0 的普通网页命令框发送的是 shell 命令,没有预设动作选择器。 因此,把机器改成 allowlist 后,直接在这个框里输入 nginx-status 并不能调用上面的动作。这是当前协议能力和管理界面的一个差距,不能把配置示例写成页面上已经完整可用的操作流程。
full 则需要更谨慎。按当前部署方式,客户端以 root 运行,获准的命令也有相应权限。签名只能证明请求符合授权检查,无法判断这个命令是否写错、是否会删掉重要文件。
本地权限也不是不可改变的保险箱:一条已经获准的 root 命令,本身就可能修改客户端配置。它限制的是未经授权的请求,不能把已授权的 root 操作变成低权限操作。
浏览器保存私钥,换浏览器也会改变信任关系
命令签名密钥由浏览器通过 WebCrypto 生成,私钥被设置为不可导出,密钥对保存在 IndexedDB 中。正常流程只向服务端登记公钥,服务端不保存这把签名私钥。
这带来一个很实际的使用差异:换一台电脑,或者清掉浏览器站点数据以后,即使还能登录后台,也不代表还能签出原来那些机器认可的命令。
新浏览器需要生成新密钥,目标客户端也要信任新的公钥。服务端登记新公钥,不会自动让已经安装的所有客户端更新信任列表。
撤销旧密钥同样要落到客户端。只从后台删除公钥,不等于已经撤销现有机器对它的信任。 还需要从相关机器的 trusted_signers 中移除旧公钥,再按对应系统重启客户端使配置生效。
这里还有另一把容易混淆的密钥:客户端更新包的发布签名密钥。命令签名回答“这次操作是谁授权的”,发布签名回答“这个客户端更新包是否来自可信发布者”。二者用途和生命周期不同,后面的更新篇会单独展开。
两个必须讲清楚的边界
如果介绍到这里就说“服务端被攻破也没关系”,会把这套设计的能力讲过头。当前版本至少有两个重要边界。
服务端同时提供管理页面
私钥不可导出,限制的是正常 WebCrypto 接口直接导出私钥材料。它不意味着同源恶意脚本无法调用这把密钥签名。
Fleetd 的管理页面由服务端提供。如果服务端被控制,攻击者可能修改后续提供给浏览器的 JavaScript。被篡改的页面可以显示一份内容,却构造另一份请求,或者诱导浏览器使用已有密钥。
因此,当前签名机制确实能让客户端拒绝被篡改、没有可信签名的请求,但可信的前端代码仍然是前提。页面里的确认框也依赖同一份前端,无法独立证明自己没有被修改。
把签名工具与管理服务端分开分发,是进一步缩小这层依赖的方向。项目设计文档提到了 CLI 签名器,但 v0.3.0 还没有实现,不能把它算作当前已经具备的保障。
网页终端只在打开时签名
网页终端打开时,客户端会验证一份 kind: shell 请求,检查可信签名、目标和 full 权限。
终端打开以后,后续按键数据通过现有会话传递,没有逐条浏览器签名。服务端如果被控制,就可能向已经打开的终端注入输入。
所以,一次签名打开终端,并不代表终端生命周期内的每条输入都获得了同等保护。需要临时交互时再打开,用完关闭;能够用明确的一次性命令完成的操作,通常更容易说明和审查授权范围。
同样,签名也不负责隐藏命令内容和输出。客户端与服务端之间的加密连接保护传输路径,但服务端本身是通信端点,不能把它描述成浏览器到客户端、连服务端也无法读取的完整端到端保密。
安全设计要看拒绝路径
实现远程命令,最直观的演示是点一下按钮,然后看到输出。对于签名验证,更值得关注的是哪些请求必须被拒绝。
Fleetd 当前的授权测试里覆盖了这些场景:签名后修改命令、使用不受信密钥、目标不匹配、请求过期、时间过于超前、权限不允许,以及客户端重新加载状态文件后收到同一个请求 ID。
这些测试分别约束一条规则,方便后续修改时发现回归。不过,它们不是完整的安全审计,也不会自动覆盖被篡改的管理页面、终端输入链路,以及所有部署环境。
对自托管使用者来说,这套设计最有价值的地方,是能把执行资格说清楚:哪把密钥受到信任,哪些机器在授权范围内,请求何时有效,本机允许什么,以及重复请求如何处理。出了问题,也能沿着这些条件定位。
Fleetd 的这部分还有继续完善的空间,比如预设动作的管理界面、独立签名工具,以及终端会话的保护。把现有边界写清楚,也能让后面的改进有具体目标。
下一篇聊一个更贴近部署的问题:同一个 Go 客户端,怎样同时运行在普通 Linux 服务器和 OpenWrt 路由器上。
项目与相关资料: