用 Python 写一个只读优先的 SSH 批量运维 CLI:从配置模型到命令沙箱

举报
yd_294443091 发表于 2026/10/22 10:47:39 2026/10/22
【摘要】 只有 5 到 20 台服务器时,装 agent 太重、开 Web 面板太险、裸脚本又没有任何安全边界。本文记录一个纯 SSH 的 Python 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 的执行顺序被固定为:

  1. 解析目标(名称 / --all / --group / --tag)
  2. 对所有目标先做命令校验
  3. 打印完整的执行计划(目标清单 + 物理机数量)
  4. 逐台执行,单台失败不中断后续目标
  5. 输出汇总,有任何失败则返回码 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 的几十行),而是三件事:

  1. 把"只读"定义清楚——不是"看起来安全的命令",而是参数级别的读写区分。
  2. 把"批量"做成可预演的——先校验全部目标、先打印执行计划,再动手。
  3. 把"失败"做成可分辨的——单台失败不中断、返回码区分超时与启动失败。

如果只是想省几个终端窗口,写个 for 循环就够了;但如果你希望半年后回看这次操作时,能清楚知道"当时到底对哪些机器执行了什么",那这三件事就绕不过去。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。