用 Python 写一个只读优先的 SSH 批量运维 CLI:从配置模型到命令沙箱
一、问题:六台机器,六个终端窗口
手里一旦有超过三台 Linux 服务器,日常运维就会退化成一种体力活:开六个终端窗口,逐个 ssh 进去,敲同样的 uptime、df -h,再把输出复制到记事本里比对。更麻烦的是"只读巡检"和"高危操作"混在同一个 shell 里,手一抖就把 rm 敲到了生产机上。
市面上的方案大致三类:装 agent 的集中管控平台、带公网面板的 Web 管理系统、以及纯脚本。前两类对"只有 5 到 20 台机器、又想保持审计可控"的个人或小团队来说太重;纯脚本又没有任何安全边界。本文记录的是第三条路——用 Python 写一个只做一件事的 CLI:基于 SSH 公钥,批量执行经过白名单校验的命令,不装 agent、不开端口、不做 Web 面板。
二、设计取舍:能力越少,越不容易出事
落地时定了几条硬约束,后面所有代码都围绕它们展开:
- 零常驻进程:远程服务器上不安装任何东西,只依赖系统自带的
sshd。 - 零开放端口:管理端不监听任何端口,不存在被扫到面板的风险。
- 密钥即身份:认证完全交给 SSH 公钥,工具本身不存储、不传输任何口令。
- 默认拒绝:命令必须命中只读白名单才允许执行;想跑白名单外的命令,必须人工加
--yes。
这四条约束带来的直接后果是:工具不能做文件分发、不能做交互式会话、不能做定时任务下发。看起来是缺陷,但对一个巡检工具来说,"做不了"恰恰是最有价值的安全属性。
三、配置模型:物理机与登录身份分离
真实环境里经常出现"一台机器两个账号"的情况:日常用普通用户 Suzume,偶尔需要 root。如果配置文件按"账号"组织,同一台机器就会出现两条记录,容易误判成两台机器。所以清单里把物理服务器和登录身份做成了两层:
servers:
- name: web-prod
host: 203.0.113.10
port: 22
groups: [prod, web]
tags:
os: ubuntu
role: web-node
accounts:
- name: deploy
user: deploy
role: standard
key: ~/.ssh/id_ed25519_ops
- name: root
user: root
role: superuser
key: ~/.ssh/id_ed25519_ops
加载后这两个账号会展开成两个可寻址目标:web-prod:deploy 和 web-prod:root。list 子命令还会额外统计"物理机数量",避免把多个账号误读成多台机器:
$ server-manager list
web-prod:deploy server=web-prod deploy@203.0.113.10:22 groups=prod,web tags=os=ubuntu,role=web-node
web-prod:root server=web-prod root@203.0.113.10:22 groups=prod,web tags=os=ubuntu,role=web-node,account_role=superuser
Total: 2 target(s) on 1 physical server(s).
这里有一个容易被忽略的细节:account_role 是从账号级字段自动提升到标签的。这样一来,--tag account_role=superuser 就能一键筛出所有 root 身份,而不需要在每台机器上手工重复写标签。
四、安全模型:三道闸门,缺一不可
命令校验拆成了三层,任意一层拦截都会直接拒绝执行。这是整个工具最核心的部分,也是最容易写漏的部分。
第一道:危险程序黑名单
reboot、rm、mkfs、dd、iptables、apt 这类命令一旦出现在任意位置就直接拒绝。systemctl 则单独处理——只有 status 放行,stop/restart/disable/mask/kill 一律拦下。
第二道:Shell 操作符拦截
安全模式下禁止 ; | && || >,同时禁止反引号、$、换行和 &。这一条挡的是"白名单绕过":只要允许管道,uptime | xargs reboot 这类拼接就能绕过第一道闸门。
第三道:只读白名单
前两道通过后,命令还必须匹配白名单前缀,或者命中带参数约束的白名单规则。参数约束比前缀匹配严格得多,比如 journalctl 只接受 -u/-n/-p/--since 等读取类参数,--vacuum-size 这种会删日志的参数会被拒绝。
实测效果(同一份校验函数,逐条验证):
PASS 'uptime'
PASS 'df -h'
PASS 'journalctl -u nginx -n 50 --no-pager'
BLOCK 'journalctl --vacuum-size=100M' -> command is not in the read-only allowlist
PASS 'date -Iseconds'
BLOCK 'date -s 12:00' -> command is not in the read-only allowlist
PASS 'systemctl status nginx'
BLOCK 'systemctl stop nginx' -> systemctl stop can change remote service state
BLOCK 'uptime; reboot' -> contains a high-risk program or file operation
BLOCK 'df -h | head' -> shell operators are not allowed in the safe command mode
BLOCK 'uptime`whoami`' -> shell expansion and control operators are not allowed
注意 date 那组:-Iseconds 是只读格式化,-s 12:00 是改系统时间。如果只用"前缀匹配 + 黑名单",date -s 会漏过去。所以白名单规则必须细化到参数级别,能区分"读"和"写"的参数集合。
五、执行模型:预检全部目标,再逐个执行
批量执行最容易踩的坑是"执行到第三台才发现命令本身不合法",前两台已经跑完了。所以 run 的执行顺序被固定为:
- 解析目标(名称 /
--all/--group/--tag) - 对所有目标先做命令校验
- 打印完整的执行计划(目标清单 + 物理机数量)
- 逐台执行,单台失败不中断后续目标
- 输出汇总,有任何失败则返回码 1
真实批量执行的效果:
$ server-manager run --group prod "cat /etc/os-release"
Execution plan: 3 target(s) on 3 machine(s).
...
[beijing-web] OK $ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.4 LTS"
VERSION_ID="24.04"
[osaka-web] OK $ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.4 LTS"
VERSION_ID="24.04"
[sh-db] OK $ cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
VERSION_ID="13"
Summary: 3 succeeded, 0 failed, 3 total.
逐台串行而不是并发,是有意为之:并发会让"某台机器卡住"变成一个难以定位的问题,而串行至少能清楚地知道停在哪一台。20 台以内的规模,串行的总耗时可接受。
六、几个踩过的坑
坑 1:--timeout 必须写在目标之前
因为 run 的位置参数是 targets_and_command(一个或多个目标名 + 最后一个是命令),把 --timeout 写在目标后面会被当成命令的一部分:
$ server-manager run web:deploy --timeout 5 "uptime"
error: unrecognized arguments: uptime
正确写法是把可选参数放在子命令之后、位置参数之前:
$ server-manager run --timeout 5 web:deploy "uptime"
坑 2:Windows 本机与 SSH 目标不能混跑
如果清单里同时有 transport: local 的 Windows 本机和 Linux SSH 目标,--all 会把两类目标混在一起。Windows 侧走的是无 Profile 的非交互 PowerShell,POSIX 侧走 shlex 校验,两套规则无法用一个字符串同时满足。所以工具直接禁止混合执行,只放行 whoami/hostname 这类两端语义一致的命令,其余情况必须拆分目标。
坑 3:Windows 本机的"只读"模板要比 Linux 严得多
Linux 侧可以用 shlex 做词法分析,PowerShell 不行——同一个命令在不同版本里语义可能不同。所以 Windows 本机干脆只接受四个精确模板:whoami、hostname、Get-Date、Get-Location,大小写不敏感,不接受任何额外参数或表达式。这条限制很粗暴,但把"PowerShell 表达式注入"这一整类风险直接消除了。
坑 4:超时与启动失败要区分返回码
批量执行时,"命令超时"和"进程根本没起来"是两种完全不同的故障。前者是目标机器的问题,后者往往是本机环境的问题(比如 ssh 不存在)。所以返回码做了区分:超时固定为 124(与 GNU timeout 一致),进程启动失败固定为 127。
七、这套做法的边界
需要说清楚它不适合什么场景:
- 需要交互式会话(
top、vim、需要输密码的sudo)——它只做一次性命令执行。 - 需要文件分发或配置同步——应该用 Ansible 或
rsync。 - 需要准实时监控——应该接云监控或 Prometheus。
- 超过几十台的规模——串行执行会成为瓶颈,需要换成并发 + 连接池。
它适合的场景非常具体:手里有 5 到 20 台机器,日常只需要跑只读巡检命令,希望有一条明确的、可审计的、不会误操作的执行通道。在这个范围内,"能力少"换来的是"不会出事",这笔交易是划算的。
八、小结
回头看,这个工具真正有价值的部分不是 SSH 调用(那是 subprocess 的几十行),而是三件事:
- 把"只读"定义清楚——不是"看起来安全的命令",而是参数级别的读写区分。
- 把"批量"做成可预演的——先校验全部目标、先打印执行计划,再动手。
- 把"失败"做成可分辨的——单台失败不中断、返回码区分超时与启动失败。
如果只是想省几个终端窗口,写个 for 循环就够了;但如果你希望半年后回看这次操作时,能清楚知道"当时到底对哪些机器执行了什么",那这三件事就绕不过去。
- 点赞
- 收藏
- 关注作者
评论(0)