1. 先搞清楚:为什么这类活非得自己写脚本
Kylin Server V10 下做 OpenSSH 安全漏洞的离线修复,这几年几乎成了内网运维的固定动作。我接触过的场景高度相似:一批跑在隔离网里的服务器,装的是 Kylin Server V10,系统上跑着 OpenSSH,某天安全扫描报告出来,一串 SSH 相关的高中危条目,要求限期闭环。机器不能出网,不能连外网源,甚至很多环境连内网的 yum 源都没有同步过新包。这时候你要做的不是"点一下升级",而是把离线包、依赖、配置、回滚、验证这一整套东西,做成一个能在几十上百台机器上重复执行的离线修复脚本。
这件事的价值点在于:它不是一次性的手工操作,而是一个可复现、可审计、可回滚的交付物。适合谁看?我把它分成三类人。第一类是手里有几台到几十台内网服务器、被扫描报告追着跑的运维同学,你需要的是能直接改改就用的脚本骨架。第二类是做等保整改、交付验收的工程同学,你需要的是能说清楚"我做了什么、依据是什么、失败了怎么办"的流程。第三类是刚接触 Linux 加固、想知道升级 OpenSSH 到底会踩哪些坑的新手,你可以把这篇当成一份带注释的操作手册。
我先把结论摆出来:绝大多数情况下,正确的做法是用官方签名的离线 RPM 包做原地升级,而不是源码编译覆盖系统自带的 OpenSSH。原因后面会展开讲,但你要先建立这个判断,因为它直接决定了脚本的形态。源码编译在离线环境里听起来"可控",实际上是给自己挖坑——依赖链、PAM 接线、SELinux 上下文、后续补丁管理,每一项都可能让你在半夜两点还连不上机器。
还有一点得提前说明:任何涉及 sshd 的操作,本质都是在给自己的远程登录通道动手术。脚本写得再漂亮,只要中间有一个环节让 sshd 起不来,你就从"修漏洞"变成"救火"。所以这篇里我会反复强调带外通道、灰度、看门狗这几件事,它们不是可选项,是前置条件。
1.1 在线升级这条路为什么在内网里基本走不通
很多人第一反应是yum update openssh,在有网环境里这确实是最省事的路子。但在 Kylin V10 的隔离网场景里,这条路会连续撞上三堵墙。
第一堵墙是软件源不可达。系统默认的 repo 指向外部地址,隔离网里解析不了也连不上,命令会卡在"正在尝试其他镜像"上很久,最后报一堆超时。有些人会改 repo 指向内网镜像站,但如果内网镜像站没有同步过对应版本的更新包,结果一样。
第二堵墙是版本锁定策略。不少单位对生产环境有明确的版本基线,不允许整包大版本跳变,只允许打安全补丁。这时候你需要精确知道目标版本号、需要哪几个子包、依赖哪些库,而不是让 yum 自己挑一个"最新"。
第三堵墙是操作不可追溯。yum 一条命令下去,装了哪些包、覆盖了哪些配置、产生了哪些 .rpmnew 文件,全靠事后翻/var/log/yum.log猜。而在验收场景里,你需要的是脚本自己产出一份能贴进报告的日志。
所以离线修复脚本的第一个定位就很清楚了:把"包从哪来、装了哪些、配置改成什么样、失败怎么退回"这四个问题,全部固化进代码里。
1.2 脚本化修复要解决的三个真实痛点
我总结下来,手工做这件事最容易翻车的地方有三个,脚本设计就是围绕它们展开的。
痛点一:升级完 sshd 起不来,人已经断线了。这个最要命。OpenSSH 升级会替换/usr/sbin/sshd二进制,替换完必须重启服务才生效。而重启的瞬间,你这台机器上的 SSH 会话如果依赖这个进程,就可能直接断掉,而且新进程如果因为配置错误起不来,你就彻底进不去了。脚本必须解决"重启后自动验证、失败自动回滚"这个问题。
痛点二:配置文件被悄悄改掉。RPM 升级时,如果/etc/ssh/sshd_config被本地改过,新包里的同名文件不会直接覆盖,而是生成.rpmnew。反过来,如果包内配置有变化而你本地没改过,旧文件会变成.rpmsave。很多人升级完发现"怎么 root 又能密码登录了",就是新配置文件顶掉了原来的加固项。
痛点三:老客户端连不上了。OpenSSH 8.8 之后默认禁用了ssh-rsa(SHA-1)签名算法,一批老旧的运维工具、老版本客户端、某些网络设备的 SSH 模块会直接握手失败,报no matching host key type found。这个问题的排查成本极高,因为服务端日志只写一句"无法协商",看不出是算法问题。
脚本要在这三点上给出明确处理:重启前做配置语法检查、重启后做端口探活、失败自动还原备份;升级前比对新旧配置、自动合并加固项;升级前评估客户端兼容性、必要时显式放开算法白名单并记录在案。
1.3 三条技术路线的取舍:我为什么选 RPM 原地升级
摆在面前的路其实有三条,我按推荐度从高到低说。
路线一:官方离线 RPM 包原地升级。拿到与系统基线匹配的 openssh、openssh-server、openssh-clients、openssh-askpass 等一组包,用rpm -Uvh或本地 repo 方式升级。优点是保持包管理体系完整,后续还能被包管理器识别、能被扫描器正确读到版本号,PAM 和 SELinux 上下文由包脚本自动处理。缺点是必须拿到签名正确的包,且依赖要凑齐。
路线二:源码编译替换。下载源码包,./configure --prefix=/usr --sysconfdir=/etc/ssh --with-pam --with-selinux编译安装。优点是版本自由、可以精细控制编译选项。缺点是它绕过了包管理器,rpm -q openssh依然显示旧版本,扫描器照样报警;升级时还得手工处理 PAM 模块路径、systemd 单元文件、主机密钥权限;一旦出问题,卸载极麻烦。
路线三:只做配置层加固。不改二进制,只收紧 sshd_config、限制来源、改端口、上密钥认证。优点是零风险、可立即回滚。缺点也很明显:扫描器报的是二进制版本漏洞,配置改得再严,版本号不变,报告关不掉。
我的实际做法是以路线一为主,路线三作为兜底和补充。也就是说,脚本主干是离线包升级,同时把配置加固项作为独立模块,这样即使某台机器因为依赖问题升不上去,也能先落地配置层缓解措施,把风险窗口压小。路线二我只在极端情况用——比如确实拿不到匹配的包,且机器本身已经是被改造过的系统。这种情况我会在脚本里单独留一个分支,并且强制要求带外通道可用才能执行。
2. 动手前的盘点:版本、依赖、备份,一个都不能省
写脚本之前,先在至少一台同版本机器上把手工流程跑通。这一步省不得,因为脚本只是把手工步骤固化下来,手工都跑不通的流程,写成脚本只会错得更快。
2.1 把当前版本信息摸清楚
第一件事是确认三样东西:操作系统小版本、OpenSSH 版本、OpenSSL 版本。这三个是绑在一起的,OpenSSH 依赖 OpenSSL 的 ABI,跨版本组合极易出问题。
# 系统版本 cat /etc/kylin-release 2>/dev/null || cat /etc/os-release uname -r # OpenSSH 版本(注意 -V 输出到 stderr) ssh -V 2>&1 # 已安装的 ssh 相关包 rpm -qa | grep -i openssh # OpenSSL 版本 openssl version rpm -q openssl openssl-libsssh -V和rpm -q openssh的结果可能不一致,这个差异要特别留意:如果之前有人做过源码编译覆盖,二进制版本会比包版本高,这时你再用 RPM 升级,会出现"降级"或者文件冲突。脚本里必须把这两个值都记下来,并且做一次一致性判断。
我一般会加一条判断:如果ssh -V显示的版本高于rpm -q openssh显示的版本,直接中止并告警,提示"该机曾被手工覆盖安装,需人工确认"。这一条拦下过好几次事故。
2.2 依赖关系预判,别等 rpm 报错才发现
离线升级最常卡住的地方是依赖。rpm -Uvh一旦缺依赖,会直接失败,而不会像 yum 那样自动去装。所以你要么把依赖全凑齐,要么在本地搭一个临时 repo 让 yum 自己解析。
凑依赖的方法是在一台与目标机同版本的机器上执行:
# 查看 openssh-server 的依赖 rpm -qR openssh-server # 更实用的方式:用 repoquery 把整棵依赖树导出来 repoquery --requires --resolve --recursive openssh-server | sort -u注意repoquery来自yum-utils,如果目标环境没有,就用rpm -qR递归手工梳理。核心依赖通常是openssl-libs、krb5-libs、libselinux、pam、zlib、audit-libs、fipscheck-lib这些,一般系统自带版本就够,但如果你升级的 OpenSSH 跨度较大,可能要连带升级openssl-libs,这时候就要评估影响面了——openssl-libs是全系统共享库,动它等于动一大片,必须单独排期。
我的原则是:能不动共享库就不动。优先找"只依赖现有 openssl 版本"的 OpenSSH 包组合。如果实在躲不开,那这台机器的升级就不该塞进批量脚本里跑,单独拎出来做窗口操作。
2.3 备份策略:别只备份 sshd_config
新手最容易犯的错是只把sshd_config复制一份就开干。远远不够。我要求的备份清单是这样的:
| 备份对象 | 路径 | 为什么必须备 |
|---|---|---|
| 主配置文件 | /etc/ssh/sshd_config | 升级可能顶掉加固项 |
| 配置目录 | /etc/ssh/sshd_config.d/ | 新版支持 include,子配置容易被忽略 |
| 主机密钥 | /etc/ssh/ssh_host_* | 丢了会导致所有客户端指纹告警 |
| PAM 配置 | /etc/pam.d/sshd | 部分包升级会重置 |
| 二进制 | /usr/sbin/sshd、/usr/bin/ssh | 回滚时的最后一道保险 |
| 包清单 | rpm -qa快照 | 回滚时要知道原来装了什么版本 |
| 服务状态 | systemctl status sshd | 记录升级前是否本来就是停的 |
主机密钥单独说一下。它不在 RPM 包管理范围内,正常升级不会动它。但如果你的脚本里有一段"清理旧配置重装"的逻辑,误删了/etc/ssh/ssh_host_*,后果是所有连过这台机器的客户端都会弹指纹变更警告,安全团队会来找你问话。所以脚本里要有一条硬性保护:任何删除动作不得涉及/etc/ssh/ssh_host_*。
备份目录我习惯放在/var/backups/sshfix/$(date +%Y%m%d%H%M%S)/,带时间戳,方便回滚时选点。备份文件同时生成一个SHA256SUMS,回滚前先校验完整性,避免备份本身就是坏的。
3. 脚本的骨架怎么搭:目录、幂等、日志、锁
写运维脚本,我有一条经验:先想清楚失败长什么样,再想成功怎么走。下面这套骨架是我反复用过、比较稳的结构。
3.1 目录结构与文件清单
ssh-offline-fix/ ├── run.sh # 入口,参数解析、流程编排 ├── lib/ │ ├── precheck.sh # 环境自检 │ ├── backup.sh # 备份与校验 │ ├── upgrade.sh # 离线包升级 │ ├── harden.sh # 配置加固 │ ├── verify.sh # 升级后验证 │ └── rollback.sh # 回滚 ├── pkgs/ # 离线 RPM 包存放 ├── conf/ │ └── sshd_config.hardened ├── logs/ # 运行日志 └── state/ # 状态标记,用于幂等拆成多个文件不是为了好看,是为了能单独调用某一段做测试。比如harden.sh可以脱离升级流程单独跑,rollback.sh可以在最坏情况下手工执行。单文件大脚本在出事的时候没法局部调试,这是血的教训。
3.2 幂等设计:重复执行不能出事
批量脚本一定会被重复执行——有人跑了一半断线了,有人不确定跑没跑过又跑一遍。所以每一步都要能判断"这件事是不是已经做过了"。
版本判断是最直接的抓手:
current_ver=$(ssh -V 2>&1 | awk -F'[_,]' '{print $2}') target_ver="9.8p1" if [ "$current_ver" = "$target_ver" ]; then log "当前版本已是 ${target_ver},跳过升级" exit 0 fi但只看版本号不够。有时候版本号对了,配置却没落地。所以我在state/目录里放标记文件,每一步成功后写一个.done,失败写.failed,重跑时根据标记决定从哪一步继续。这套机制比"全部重来"安全得多,尤其在批量场景下。
3.3 日志、锁和退出码
日志这块,我要求三件事:带时间戳、双输出(终端+文件)、关键动作必须有 before/after 记录。
LOG_FILE="${BASE_DIR}/logs/fix_$(date +%Y%m%d%H%M%S).log" log() { local msg="[$(date '+%F %T')] $*" echo "$msg" | tee -a "$LOG_FILE" }锁是为了防止并发。同一台机器上两个人同时跑脚本,会出现一个在升级、另一个在回滚的荒唐局面。用flock最省事:
exec 200>"${BASE_DIR}/state/run.lock" flock -n 200 || { echo "已有实例在运行,退出"; exit 1; }退出码要分层,方便上层调度系统识别。我的约定是:0成功;10自检未通过(环境不满足,未做任何变更);20升级失败已自动回滚;30升级成功但验证异常;40需要人工介入。上层批量工具看到10可以直接跳过,看到40就停下来报警,这个区分能省掉大量无效重试。
4. 核心模块逐个拆:从自检到回滚
这一节我把几个关键模块的写法和背后的考量讲透,你可以按需取用。
4.1 环境自检模块:宁可误停,不要硬上
自检模块的作用是在动手之前把所有"会导致失败"或"会导致失联"的条件筛掉。我列了下面这些检查项,任何一项不过就退出,退出码10。
precheck() { # 1. root 权限 [ "$(id -u)" -eq 0 ] || die "需要 root 权限" # 2. 确认是目标系统 grep -qi "Kylin" /etc/os-release || die "非 Kylin 系统,中止" # 3. 根分区剩余空间 ≥ 500MB local avail=$(df -Pm / | awk 'NR==2{print $4}') [ "$avail" -ge 500 ] || die "根分区空间不足:${avail}MB" # 4. sshd 当前必须是运行状态 systemctl is-active --quiet sshd || die "sshd 未运行,需人工确认后再执行" # 5. 检查是否存在带外通道或第二个可用通道 #(此项无法自动判断,由操作人通过参数 --confirm-console 显式声明) # 6. 检查离线包是否齐全 local need=("openssh" "openssh-server" "openssh-clients") for p in "${need[@]}"; do ls pkgs/${p}-*.rpm >/dev/null 2>&1 || die "缺少离线包:${p}" done # 7. 包签名校验 rpm --checksig pkgs/*.rpm | grep -q "OK" || die "离线包签名校验失败" }第 5 项我要多说两句。脚本没法自动判断你有没有带外通道,所以必须由人显式声明。我设计成强制参数--confirm-console,不传就不给跑。这不是形式主义,是逼操作人在动手前确认一次"如果失联我怎么办"。实际执行中,这一条会让人本能地去检查一下 IPMI 或虚拟化控制台能不能登,而这个动作本身就避免了很多事故。
第 7 项的签名校验也值得展开。离线包在拷贝、转存、U 盘传递的过程中损坏是完全可能的,而损坏的包rpm -Uvh可能装到一半失败,留下一个半残的 OpenSSH。校验签名和 SHA256 是最低要求,脚本里可以再加一步sha256sum -c SHA256SUMS。
4.2 升级模块:顺序、参数、以及那几个必须的开关
升级本身不复杂,复杂的是细节。先看代码骨架:
do_upgrade() { local pkgdir="${BASE_DIR}/pkgs" cd "$pkgdir" || die "无法进入包目录" # 按依赖顺序安装,server 依赖 clients 和核心包 local order=( "openssh-*.rpm" "openssh-clients-*.rpm" "openssh-server-*.rpm" ) for pattern in "${order[@]}"; do local files=( $pattern ) [ -e "${files[0]}" ] || continue log "安装: ${files[*]}" rpm -Uvh --replacefiles --replacepkgs "${files[@]}" >>"$LOG_FILE" 2>&1 \ || { log "包安装失败"; return 1; } done return 0 }几个参数值得解释:
--replacefiles是处理文件冲突的。如果你之前做过源码编译,/usr/sbin/sshd会被包管理识别为"被他人修改",不加这个参数会报冲突。--replacepkgs是允许重复安装同版本包,配合幂等设计用。
但要注意,--replacefiles是有风险的,它可能覆盖掉别的包的文件。所以我一般会在自检阶段就判断是否存在源码编译痕迹,如果有,直接停下让人工处理,而不是靠参数硬扛。
升级顺序这个细节很多人忽略。必须先升核心包openssh,再升 clients,最后升 server。因为 server 的安装脚本会去调新版本的sshd做-t语法检查,如果顺序反了,可能出现"新 server 配老二进制"的短暂不一致。虽然概率不高,但在批量场景下,任何"概率不高"都会在几百台机器上变成"必然发生一次"。
主机密钥权限是另一个容易漏的点。包升级一般不动主机密钥,但如果你做过清理,或者之前权限就是错的,升级后 sshd 会因为密钥权限过宽(比如 0644)拒绝启动,报bad permissions。脚本里加一条修正:
chmod 600 /etc/ssh/ssh_host_*_key chmod 644 /etc/ssh/ssh_host_*_key.pub restorecon -Rv /etc/ssh/ >/dev/null 2>&1restorecon那行是修 SELinux 上下文的。在某些强制模式环境下,新装的文件上下文不对,sshd 一样起不来。这一行成本极低,收益很高,建议无脑加上。
4.3 配置加固模块:升级之后必须重新落一遍
前面说过,RPM 升级会处理配置文件的"三向合并",结果是你的加固项可能被冲掉。所以升级完成后,必须重新执行一遍加固。而且这一步要做成幂等且可审计——每次改动都记录改了什么、依据是什么。
我用的是"模板 + 差异合并"的思路:把加固项写成一个片段文件,脚本负责判断每一项目前是什么值,不是目标值就改。
apply_hardening() { local cfg="/etc/ssh/sshd_config" local bak="${cfg}.bak.$(date +%s)" cp -a "$cfg" "$bak" # key=value 形式的加固项 declare -A items=( ["PermitRootLogin"]="prohibit-password" ["PasswordAuthentication"]="no" ["PermitEmptyPasswords"]="no" ["MaxAuthTries"]="4" ["LoginGraceTime"]="30" ["ClientAliveInterval"]="300" ["ClientAliveCountMax"]="2" ["X11Forwarding"]="no" ["AllowAgentForwarding"]="no" ["LogLevel"]="VERBOSE" ) for k in "${!items[@]}"; do local v="${items[$k]}" if grep -qiE "^[[:space:]]*#?[[:space:]]*${k}[[:space:]]+" "$cfg"; then sed -i -E "s|^[[:space:]]*#?[[:space:]]*${k}[[:space:]]+.*|${k} ${v}|I" "$cfg" else echo "${k} ${v}" >> "$cfg" fi log "加固: ${k} = ${v}" done # 语法检查,不通过立即回滚配置 if ! sshd -t 2>>"$LOG_FILE"; then log "配置语法检查失败,回滚配置" cp -a "$bak" "$cfg" return 1 fi }这里有几个坑要提醒。
坑一:PasswordAuthentication no之前,先确认密钥登录是通的。我在脚本里加了一条前置判断:检查/root/.ssh/authorized_keys或目标运维账户的authorized_keys是否存在且非空。如果为空还硬把密码登录关掉,等于自己把门焊死了。
坑二:sshd_config.d/目录的优先级。新版 OpenSSH 支持Include /etc/ssh/sshd_config.d/*.conf,而且这个 Include 通常写在文件最前面。OpenSSH 的规则是第一次出现的值生效,所以子目录里的配置会覆盖主文件后面的配置。如果你的加固项写在了主文件末尾,但子目录里有个文件写了PasswordAuthentication yes,那你的加固就没生效。脚本里必须扫一遍这个目录:
if [ -d /etc/ssh/sshd_config.d ]; then log "检测到 sshd_config.d 目录,内容如下:" for f in /etc/ssh/sshd_config.d/*.conf; do [ -e "$f" ] || continue log " $f:" sed 's/^/ /' "$f" >> "$LOG_FILE" done fi扫出来之后不要贸然删除,先人工看一眼是谁加的。有些云环境或运维平台会往这里塞配置,删了会影响别的功能。
坑三:算法兼容性要显式决定,而不是被动踩雷。新版默认的算法集收紧了,老客户端连不上时,最常见的修法是在服务端放开。但放开多少、放开哪些,得有依据。我的做法是升级前先统计现有客户端用的算法:
# 升级前抓一段时间日志,统计实际协商使用的算法 grep -oE "kex: algorithm: [^ ]+" /var/log/secure* 2>/dev/null \ | awk '{print $NF}' | sort | uniq -c | sort -rn如果统计出来全是curve25519-sha256这类新算法,那你放心收紧;如果出现diffie-hellman-group14-sha1这种,说明有老设备在用,那就得在配置里针对性放开,并在报告里写清楚为什么。这比"先升级,出问题再放开"要体面得多。
4.4 重启与看门狗:这一段决定你会不会失联
前面所有步骤都可以慢慢来,只有重启这一段是"一锤子买卖"。我的方案是延时重启 + 独立看门狗。
思路是这样的:不要在脚本主进程里直接systemctl restart sshd,而是用一个systemd-run或者在nohup里跑一个延时任务,比如 10 秒后执行重启,同时这个任务在重启后 30 秒检查端口,失败就自动把备份的二进制和配置还原回去。
schedule_restart_with_watchdog() { local backup_dir="$1" cat > /tmp/sshd_watchdog.sh <<'EOF' #!/bin/bash BACKUP_DIR="$1" LOG="/var/log/sshd_watchdog.log" sleep 5 systemctl restart sshd sleep 15 if systemctl is-active --quiet sshd && \ (echo > /dev/tcp/127.0.0.1/22) >/dev/null 2>&1; then echo "$(date) 重启成功,服务正常" >> "$LOG" exit 0 fi echo "$(date) 重启后检测失败,开始回滚" >> "$LOG" cp -af "${BACKUP_DIR}/sshd" /usr/sbin/sshd cp -af "${BACKUP_DIR}/sshd_config" /etc/ssh/sshd_config chmod 600 /etc/ssh/ssh_host_*_key 2>/dev/null systemctl restart sshd echo "$(date) 回滚完成,当前状态:$(systemctl is-active sshd)" >> "$LOG" EOF chmod +x /tmp/sshd_watchdog.sh setsid nohup /tmp/sshd_watchdog.sh "$backup_dir" >/dev/null 2>&1 & log "已安排延时重启与看门狗,10 秒后执行" }这个方案的关键在于:重启动作脱离了当前 SSH 会话的进程树。即使你的会话断了,看门狗照样跑完,要么成功、要么回滚,机器总能回到一个能连的状态。
有几点必须配合:
第一,端口探测不要只探测 22。如果你改过端口,探测目标要跟着改。更稳的做法是解析sshd_config里的Port值再探测。
第二,探测要重试。sshd 重启后可能需要几秒钟才完全就绪,一次失败就回滚会误判。所以是"重试 5 次、每次间隔 3 秒",全失败才算失败。
第三,回滚用的二进制一定要在升级之前就备份好。有些人备份配置文件很积极,二进制忘了拷,出事的时候只能去别处找同版本包,时间全耗在这上面。
第四,如果环境支持,最好再叠一层带外兜底——IPMI、虚拟化控制台、串口。脚本里没法自动操作这些,但可以在自检阶段强制操作人确认可用。双保险永远比单保险踏实。
5. 完整走一遍:从拿到包到批量落地
前面讲的是零件,这一节把它们装起来跑一遍。我以"一次典型的批量修复"为线索。
5.1 离线包的准备与校验
包的来源通常是官方渠道或者内部制品库,具体渠道按你单位的规范来。拿到之后,第一件事不是急着装,是在你手上的一台同版本测试机上先装一遍。
# 在一台同版本测试机上 mkdir -p /tmp/sshtest && cd /tmp/sshtest rpm2cpio openssh-server-*.rpm | cpio -idmv /usr/sbin/sshd -V 2>&1 # 看这个包里的版本对不对rpm2cpio解包看内容是个好习惯,可以在不影响系统的情况下确认包里的文件清单和版本。确认无误后再做正式安装测试。
包的组织上,我建议按版本号建目录,不要平铺:
pkgs/ ├── 9.8p1/ │ ├── openssh-9.8p1-*.rpm │ ├── openssh-server-9.8p1-*.rpm │ ├── openssh-clients-9.8p1-*.rpm │ └── SHA256SUMS └── 9.6p1/ └── ...保留旧版本目录的价值在于回滚。万一新版本在某批机器上出了共性问题,你需要降回旧版本,这时候手里有旧包能省掉几小时。
5.2 单机试跑:先看它怎么失败
正式跑之前,在一台允许重启、允许短暂失联的机器上做完整试跑。试跑时我建议故意制造一次失败,验证回滚路径:
# 故意把 sshd_config 改坏,看脚本能不能识别并回滚 echo "ThisIsNotAValidDirective yes" >> /etc/ssh/sshd_config ./run.sh --confirm-console # 预期:sshd -t 报错,脚本回滚配置,退出码 20这种"主动找失败"的测试,比一路顺利跑通的测试有价值十倍。顺风顺水的路径只能证明脚本能跑,回滚路径才能证明它可信。
试跑通过后,把这台机器的日志完整留档。批量阶段对比日志,能快速发现"某几台机器的输出和标准样本不一样",这比逐台检查快得多。
5.3 批量下发:灰度、串行、失败即停
批量执行有三条铁律。
铁律一:按批次灰度。先 1 台,再 5 台,再 20%,最后全量。每一批之间观察至少 30 分钟,重点看两件事:新连接是否正常、业务侧是否有报错。SSH 挂掉有时不会立刻显现,等业务重连失败才暴露,那时候可能已经过了几个小时。
铁律二:串行执行,不要并发。同一时间只对一台机器操作。并发看起来快,但一旦有问题,你会同时面对一堆失联机器,排查顺序都理不清。串行虽然慢,但每台机器失败,你都能立刻停下来看清原因。
铁律三:失败即停,不要"跳过继续"。这一条最容易被违反。批量工具默认行为往往是"记录失败、继续下一台",听起来合理,实际上等于让一个已知问题扩散。我要求第一台失败就停下,查清楚再说。
批量下发如果用了自动化工具,命令大概是这个样子(以常见的批量执行工具为例,具体按你手头的工具调整):
# 事前把脚本目录推到目标机 for host in $(cat hosts.txt); do scp -o BatchMode=yes -r ssh-offline-fix/ root@${host}:/opt/ 2>&1 done # 串行执行,任何一台失败立即退出 while read -r host; do echo "=== 开始处理 ${host} ===" ssh -o BatchMode=yes -o ConnectTimeout=10 root@${host} \ "/opt/ssh-offline-fix/run.sh --confirm-console --noninteractive" rc=$? echo "=== ${host} 退出码: ${rc} ===" if [ $rc -ne 0 ]; then echo "非零退出,停止批量" break fi done < hosts.txt--noninteractive这个参数是给批量模式用的,作用是去掉所有需要人确认的交互提示。但要注意,--confirm-console这个确认在批量模式下不能省,应该由操作人在批量开始前统一确认一次,通过一个总开关传递下去,而不是每台机器弹一次。
5.4 验证清单:怎么算"修好了"
升级完不能只看ssh -V。我用的验证清单是这样的:
| 验证项 | 命令 | 期望结果 |
|---|---|---|
| 二进制版本 | ssh -V | 目标版本 |
| 包版本一致 | rpm -q openssh | 与二进制版本一致 |
| 服务状态 | systemctl is-active sshd | active |
| 配置语法 | sshd -t | 无输出 |
| 端口监听 | ss -tlnp | grep sshd | 监听预期端口 |
| 本机回环连接 | ssh -o BatchMode=yes -o StrictHostKeyChecking=no localhost true | 返回 0 |
| 加固项生效 | sshd -T | grep -i passwordauth | no |
| 主机密钥权限 | ls -l /etc/ssh/ssh_host_*_key | 600 |
| 日志无异常 | journalctl -u sshd --since "10 min ago" | 无 error |
sshd -T这条命令特别好用,它会把最终生效的配置全部打印出来——注意是最终生效的,已经处理过 Include 和优先级。比起去文件里 grep,这个结果可信得多。加固项是否真的生效,看这个输出最准。
6. 踩坑记录:这些坑我都替你踩过了
下面这些问题,都是我在实际批量执行里遇到过的,按出现频率排序。
6.1 高频问题速查表
| 现象 | 根本原因 | 处理方式 |
|---|---|---|
| 升级后重启失败,sshd 起不来 | 配置语法错误或主机密钥权限异常 | 看门狗自动回滚;检查sshd -t输出 |
老客户端报no matching host key type | 新版默认禁用ssh-rsa(SHA-1) | 视情况放开HostkeyAlgorithms +ssh-rsa,并记录原因 |
| 升级后密码登录又能用了 | .rpmnew顶掉加固配置 | 升级后强制重跑加固模块 |
rpm -Uvh报依赖缺失 | 离线包依赖未凑齐 | 补齐依赖或改用本地 repo 方式 |
rpm -q openssh版本没变但ssh -V变了 | 曾源码编译覆盖 | 自检阶段拦截,人工处理 |
日志里Connection closed by authenticating user激增 | 客户端认证算法被拒 | 检查PubkeyAcceptedAlgorithms配置 |
| 批量执行中途大量失败 | 并发执行或包不完整 | 改串行,逐台确认 |
| SELinux 环境下 sshd 无法启动 | 文件上下文被破坏 | restorecon -Rv /etc/ssh /usr/sbin/sshd |
6.2 几个特别值得展开的坑
.rpmnew和.rpmsave的处理。升级完一定要扫一遍这两个后缀的文件:
find /etc/ssh -name "*.rpmnew" -o -name "*.rpmsave"sshd_config.rpmnew表示"你的配置被保留了,新版本的配置放在这里",你需要人工比对,把新增的有价值的选项合并进去。sshd_config.rpmsave表示"新版本配置已生效,你的旧配置被备份了",这种情况要特别警惕,因为你的加固项可能已经失效。我的脚本里,只要扫到这两个文件,就强制重跑加固模块,并在日志里高亮标出。
PAM 配置被重置。sshd的 PAM 配置在/etc/pam.d/sshd,个别包升级会重置它。重置后可能出现"密钥能登、密码登不了"或者反过来的诡异现象。脚本里把 PAM 配置也纳入备份和比对范围,升级后做一次 diff:
diff /var/backups/sshfix/${TS}/pam_sshd /etc/pam.d/sshd >> "$LOG_FILE" 2>&1有差异就记下来,人工判断是否要还原。
老旧设备的算法兼容。这个问题前面提过,但要再强调一次处理方式:不要一上来就全局放开,而是按来源限制。OpenSSH 支持在Match块里针对特定来源做差异化配置:
Match Address 192.168.10.0/24 PubkeyAcceptedAlgorithms +ssh-rsa HostkeyAlgorithms +ssh-rsa这样既解决了老设备的问题,又不至于把整个服务端的算法强度拉低。这个做法在等保验收时也更好解释——"我们做了精准放开,而不是全面降级"。
MaxStartups和握手风暴。批量机器在同一时间点集中重连,可能触发MaxStartups限制(默认 10:30:100),表现为部分客户端随机连接失败。如果你是在业务高峰期做的升级,很可能撞上这个。脚本里可以把这项适当调高,或者更稳妥的做法是错开升级时间窗:
MaxStartups 30:30:200日志文件被轮转导致排查断档。升级过程中如果正好赶上logrotate,/var/log/secure被切走,你的排查线索就断了。脚本里可以在动手前先做一次日志快照:
cp -a /var/log/secure "${BACKUP_DIR}/secure.snapshot" 2>/dev/null || true journalctl -u sshd --since "-1 hour" > "${BACKUP_DIR}/sshd_journal.log" 2>/dev/null || true这两行成本几乎为零,但出事的时候是救命的东西。
7. 我个人的几条实操心得
写到这里,流程和代码都差不多了,最后分享几条不那么"技术"、但实际影响很大的经验。
第一条,永远在动手前确认你还有第二条路进去。我现在的习惯是,每次要动 sshd 之前,先开一个独立的 SSH 会话挂着不关,同时确认带外通道能登。这两个条件不满足,我就不动。听起来麻烦,但它把"失联"这件事从"灾难"降级成了"麻烦",性质完全不同。
第二条,脚本的日志是交付物的一部分,不是副产品。很多人写完脚本就不管日志了,输出一堆xxx ok。等到要写整改报告、要跟安全团队解释"你到底改了什么",就开始翻历史命令。我现在的做法是,脚本每次运行产出一个结构化的摘要段,包含:升级前后版本、改动的配置项及其新旧值、执行时间、退出码、验证结果。这段摘要直接可以贴进报告,省掉大量事后整理。
第三条,别追求"一次脚本解决所有问题"。我见过有人想写一个脚本同时处理 OpenSSH 升级、系统补丁、配置加固、账号清理,最后这个脚本谁也不敢跑。我的做法是拆开:这个脚本只做 OpenSSH 相关的离线修复,其他事情各归各的脚本。职责单一,出问题时能立刻定位是哪一段,也不会因为一个无关模块的 bug 阻塞整条链路。
第四条,把"回滚"当成一等公民来写。大多数脚本里,回滚是最后补的几行代码,简单粗暴。但真正出事的时候,你依赖的恰恰是这几行。我的要求是:回滚逻辑要独立成文件、要能单独调用、要有自己的日志、要经过至少一次真实的失败演练。前面说的"故意改坏配置看它能不能回滚",就是这个意思。
第五条,版本跨度大的时候不要硬上。如果要从很老的版本一路跳到最新版,中间隔了好几个大版本,别指望一个脚本一次到位。更稳的做法是分两步:先升到一个中间稳定版,跑一段时间确认没问题,再往上升。每步之间留观察期。虽然慢,但在生产环境里,慢就是快。
这套东西我用了几年,从几台机器做到上百台,脚本改了很多版,但骨架一直没变:自检拦住不该跑的、备份保证退得回、看门狗保证不失联、日志保证说得清。把这四件事做到位,剩下那些参数和命令,都是可以现查的细节。