简介:针对 Rocky Linux 9.5 上 OpenSSH 版本偏低、安全加固项分散的问题,这套升级加固资料包给出了一份可离线落地的处理方案,面向负责服务器安全基线达标、等保整改与远程运维的系统管理员及运维工程师。压缩包共 6 个文件、约 10.96MB,主体是 5 个 x86_64 架构的 rpm 包,覆盖 openssh 主程序及客户端、服务端组件,并附带 chkconfig、initscripts 等升级途中常被连带依赖的基础包;另有 1 个 sh 脚本负责串联安装顺序、版本切换与 sshd 服务重启校验,减少手工编译带来的兼容风险。资源已在 Rocky Linux 9.5 实机验证,升级后可通过 ssh -V 确认 OpenSSL 3.5.0 与目标 SSH 版本,sshd 也能以 running 状态正常拉起。目前已有 210 人学习下载,适合需要快速完成 SSH 高版本替换并同步梳理服务依赖关系的读者参考。
1. 从 8.7p1 到 10.0p1:一套能回滚的 OpenSSH 升级加固脚本长什么样
手上有一批 rockylinux9.5 的 x86-64 机器,扫描报告盯着 OpenSSH 8.7p1 和 OpenSSL 3.0.x 不放,openssh加固 因此成了每次基线核查都要重做一遍的固定动作。这份资源是一套 shell 脚本加 RPM spec 的组合:把 ssl3.5.0 编译到独立前缀,把 ssh10.0p1 链接上去,最后产出能dnf install、能rpm -e回滚的 rpm 包,而不是make install完就撒手。它解决的不是"能不能编过",而是"编完之后三个月,别人接手还能查到版本、退回旧包、复现同一份产物"。适合谁?负责几十台以上 RHEL 系主机基线、需要给出审计证据、又不愿意每次升级都靠人工守夜的人。单机玩具环境用它有点重,但一旦机器超过五台,省下的就全是深夜排障的时间。
2. 为什么不直接 make install:RHEL 系 OpenSSH 的三条替换路线
2.1 系统自带 8.7p1 的能力边界
Rocky Linux 9.5 装完默认给的是 openssh-8.7p1 那一支,这个分支从 2021 年发布至今,Red Hat 一直在做 backport,CVE 补丁是跟上的,但算法集和协议特性不会往前推。也就是说,8.7p1 不是"不安全",是"老"。它的默认 KEX 里没有 sntrup761x25519 这类混合后量子算法,默认 MAC 列表里还留着一些现在被基线扫描判为弱项的条目,sshd -T出来的ciphers、macs在合规工具的规则库里会被标黄甚至标红。
真正让人难受的是算法协商的不可控。8.7p1 里PubkeyAcceptedAlgorithms支持 rsa-sha2-256/512,但遇到只肯发 ssh-rsa(SHA-1 签名)的老设备,要么放行ssh-rsa(等于自降基线),要么让对面升级(通常推不动)。10.0p1 这一支对老算法的处理更彻底,DSA 系列基本退出历史舞台,配置里写上去会被sshd -t直接拒绝,反而逼着把历史包袱清理干净。
所以升级动机通常不是"8.7p1 有洞",而是三条:基线扫描要求特定版本号以上;需要更新的算法集来满足加密套件白名单;部分新特性(比如更细的证书约束、更新的 sftp 行为)只有在较新分支上才有。搞清楚这一点很重要,因为如果只是补 CVE,dnf update openssh就够了,根本不需要这套脚本。
2.2 源码编译直装的四个后遗症
新手最容易走的路是./configure && make && make install,五分钟搞定,ssh -V一看版本对了,收工。这条路我在早期项目里走过不止一次,每次都在一个月内还债。
第一个后遗症是文件归属。make install把二进制扔进/usr/local/bin和/usr/local/sbin,而 systemd 的sshd.service里ExecStart指向的是/usr/sbin/sshd。结果是新版本躺在/usr/local/sbin/sshd睡大觉,跑起来的还是旧的。新手会以为是"升级没生效",其实是两个二进制并存,which sshd和systemctl status sshd显示的路径对不上。
第二个后遗症是动态库依赖不可见。OpenSSH 链接了哪个 libcrypto,ldd能看,但没人会每次升级都去看。等到某次系统更新把openssl-libs升了,/lib64/libcrypto.so.3的符号变了一点点,sshd就可能在重启时抛symbol lookup error,而这台机器上你刚好把 root 登录关了。
第三个后遗症是无法回滚。编译安装没有旧版本留存,出问题只能现场重新编一个或者从备份恢复。而 SSH 这种"唯一入口"服务,回滚窗口往往只有几分钟。
第四个后遗症是批量失效。一台机器手工编还行,二十台就是二十次人为差异,最后没人说得清哪台是什么参数编的。RPM 的价值就在这儿:同一份 spec、同一份源码、同一个%{release},产物字节级可对比。
2.3 RPM 接管:dnf 可查、可回滚、可批量
把 10.0p1 打成 rpm,本质上是让包管理器接管文件所有权。升级后rpm -q openssh能报出版本和 release,rpm -ql openssh-server能列出所有落地文件,rpm -V能校验文件是否被手改过,dnf history能直接回滚到上一个事务。这些能力在出故障时值多少钱,经历过一次"改完 sshd_config 断开连接"的人都懂。
三条路线的对比大致是这样:
| 路线 | 版本可控 | 可回滚 | 批量一致 | 审计证据 | 维护成本 |
|---|---|---|---|---|---|
| 系统自带 + dnf update | 否(受仓库约束) | 是 | 是 | 有 | 最低 |
| 源码 make install | 是 | 否 | 否 | 无 | 高 |
| 自建 RPM | 是 | 是 | 是 | 有 | 一次性投入 |
选 RPM 的代价是前期要写 spec、要处理依赖和冲突,一套 spec 调通大概要半天到一天。但只要机器数量超过三台,这笔投入就回本了。
还有一个常被忽略的点:RPM 打包强迫你把"配置变更"和"二进制变更"分开。%config(noreplace)标记的/etc/ssh/sshd_config在升级时不会覆盖本地修改,而是生成.rpmnew。这个机制逼着你在升级前就想清楚配置基线怎么管,而不是升级完发现配置被冲掉了再骂娘。
需要说清楚的是,这条路线的边界也很明确:自建包替换的是发行版官方包,一旦官方发布同版本号的更新,dnf update会试图覆盖你的包。所以 spec 里的Epoch或Release要留出足够空间,且升级后要在/etc/dnf/dnf.conf里对 openssh 做exclude,或者干脆用本地仓库固定版本。这套策略脚本里是写死的,后面章节会展开。
3. 构建环境盘点:依赖、目录与源码校验的三步准备
3.1 构建依赖清单与最小安装集
先说结论:不要在生产机上编。找一台同版本的 Rocky Linux 9.5 x86-64 做构建机,装完基础系统后按下面的清单装依赖。为什么强调同版本?因为 glibc 版本、libselinux的 ABI、pam 的头文件都跟着发行版走,跨大版本编出来的包拿到目标机上大概率起不来。
依赖分三类:编译器工具链、OpenSSH 自己的可选特性依赖、以及 OpenSSL 的构建依赖。
# Rocky Linux 9.5 x86-64 构建机基础依赖 dnf install -y epel-release 2>/dev/null || true dnf groupinstall -y "Development Tools" dnf install -y \ gcc gcc-c++ make perl-interpreter \ zlib-devel pam-devel libselinux-devel audit-libs-devel \ krb5-devel openssl-devel \ rpm-build rpmdevtools rpmlint \ wget curl gnupg2 sha256sum这段命令里每一项都有明确用途。Development Tools组包含 gcc、make、autoconf 这一套,是最省事的装法。zlib-devel让 OpenSSH 支持压缩传输,虽然加固场景里通常会关掉Compression,但编译期支持留着不影响。pam-devel是必需的,因为 Rocky 的sshd走UsePAM yes,没有 pam 头文件编出来的 sshd 会丢掉密码认证和会话管理。libselinux-devel和audit-libs-devel对应--with-selinux和--with-audit,这两个开关在生产环境建议打开,SELinux 上下文正确能省掉后面一堆AVC denied。
rpm-build和rpmdevtools提供rpmbuild和rpmdev-setuptree。rpmlint用来做产物静态检查,能提前发现 spec 里的低级错误,比如缺%files、权限不对、未 strip 的二进制。
注意不要装openssl-devel的新版本替换系统自带的,Rocky 9.5 的openssl-devel是 3.0.x,它只提供编译期头文件,不会影响后面我们自己编的 3.5.0。真正会冲突的是openssl35-devel这类自定义包,后面章节会讲怎么隔离。
装完做一次校验:
# 确认关键工具版本,记录到构建日志 gcc --version | head -1 rpmbuild --version rpm -q rpm-build zlib-devel pam-devel libselinux-devel uname -r cat /etc/rocky-release把输出贴进构建记录里。半年后有人问"这个包在什么环境下编的",这份记录就是唯一答案。我一般会把它写进 rpm 的%description或者 changelog 里,随包走。
3.2 源码获取与哈希校验
源码从哪来?OpenSSH 官网的 release tarball,OpenSSL 官网的 release tarball。这里不写具体 URL,因为镜像站经常变,写了反而误导。判断标准是:OpenSSH 的包名形如openssh-10.0p1.tar.gz,OpenSSL 形如openssl-3.5.0.tar.gz,并且官网同时提供.asc签名文件和 sha256 摘要。
下载后必须做两件事:签名校验和哈希校验。签名校验证明包是上游发布的,哈希校验证明下载过程没损坏。两个都做,别偷懒。
# 目录约定:所有构建素材放在 /build/src 下 mkdir -p /build/src && cd /build/src # 下载后先比对官网公布的 sha256 sha256sum openssh-10.0p1.tar.gz openssl-3.5.0.tar.gz > SHA256SUMS cat SHA256SUMS # 用上游 GPG 公钥验证签名(公钥需事先导入到构建用户的 keyring) gpg --verify openssh-10.0p1.tar.gz.asc openssh-10.0p1.tar.gz gpg --verify openssl-3.5.0.tar.gz.asc openssl-3.5.0.tar.gzsha256sum的输出要和官网公布的值逐字符比对,别只看"看起来一样"。签名校验失败有两种情况:公钥没导入,或者包被改过。前者会提示Can't check signature: No public key,导入公钥重跑即可;后者会直接报BAD signature,这种包一律丢弃重下,不要抱侥幸心理。
有一个血泪经验:某些企业内部镜像会缓存 tarball 但不同步.asc文件,导致签名永远校验不过。这时候的做法是从官网直接下一份做比对,确认内容一致后再用镜像的包,而不是关掉校验。
3.3 构建根目录与 rpmbuild 树
rpmbuild 默认在~/rpmbuild下建SOURCES、SPECS、BUILD、BUILDROOT、RPMS、SRPMS六个目录。用专用构建用户比用 root 好,因为 spec 里如果有rm -rf写错路径,root 身份下后果不可逆。
# 建专用构建用户,避免 root 直接跑 rpmbuild useradd -m -s /bin/bash builder su - builder # 初始化 rpmbuild 目录树 rpmdev-setuptree ls -1 ~/rpmbuild # 把源码和 spec 放到位 cp /build/src/openssh-10.0p1.tar.gz ~/rpmbuild/SOURCES/ cp /build/src/openssl-3.5.0.tar.gz ~/rpmbuild/SOURCES/rpmdev-setuptree建出来的树是标准结构,SOURCES放源码和补丁,SPECS放 spec 文件,BUILDROOT是安装暂存区(现代 rpm 用%{buildroot}变量,位置上一致)。目录权限默认是构建用户可写,这一点很重要,因为%install阶段会大量写BUILDROOT。
如果构建机磁盘小,/home分区经常只有几十 G,编 OpenSSL 加 OpenSSH 再打包,中间产物峰值能到 2~3 G。更稳妥的做法是把_topdir指到大盘:
# 把构建根改到大容量分区,写进 ~/.rpmmacros 持久生效 echo '%_topdir /data/rpmbuild' >> ~/.rpmmacros mkdir -p /data/rpmbuild/{SOURCES,SPECS,BUILD,BUILDROOT,RPMS,SRPMS} rpmbuild --showrc | grep '_topdir'%_topdir是 rpmbuild 的全局宏,改了之后所有相对路径都跟着走。rpmbuild --showrc用来确认宏生效,输出里应该是/data/rpmbuild。这一步看着琐碎,但能避免"编到一半磁盘满,BUILDROOT 写了一半"这种最难排查的失败。
准备工作到此为止。环境干净、源码可信、目录就位,接下来才是真正容易翻车的地方:编译参数和 spec。
4. 编译与打包:OpenSSL 3.5.0 前缀选择与 OpenSSH 10.0p1 的 configure 逐项拆解
4.1 OpenSSL 3.5.0 的 Configure 参数与 install_sw
OpenSSL 3.5.0 用什么前缀,是这套方案里最关键的一个决定。选/usr/local会污染默认搜索路径,选/opt/openssl35则完全隔离。我选后者,理由后面会讲。
# OpenSSL 3.5.0 构建脚本核心段 cd /data/rpmbuild/BUILD tar xf ../SOURCES/openssl-3.5.0.tar.gz cd openssl-3.5.0 ./Configure \ --prefix=/opt/openssl35 \ --openssldir=/opt/openssl35/ssl \ --libdir=lib64 \ shared zlib \ -Wl,-rpath,/opt/openssl35/lib64 \ no-md2 no-rc5 no-ssl3 no-ssl3-method \ no-comp no-idea no-seed \ enable-ktls make -j"$(nproc)" make install_sw DESTDIR=/data/rpmbuild/BUILDROOT/openssl35-root逐项说明。--prefix=/opt/openssl35是隔离的核心,所有产物落在/opt下,和系统/usr/lib64/libcrypto.so.3完全不碰面。--openssldir指定配置和证书目录,默认会跟着 prefix 走,显式写出来是为了避免某些子目录跑到/usr/local/ssl。
--libdir=lib64让动态库落在lib64而不是lib,这是 x86-64 上的惯例,也方便后面 rpath 写死路径。shared生成.so,zlib启用压缩支持。
-Wl,-rpath,/opt/openssl35/lib64是最重要的一项。它把库搜索路径烧进二进制的DT_RUNPATH,运行时优先从这里找libcrypto.so.3,不需要改ld.so.conf,也不需要LD_LIBRARY_PATH。这一条直接决定了后面第 5 章那个坑会不会发生。
no-ssl3 no-ssl3-method no-comp no-idea no-seed是裁剪,去掉历史遗留算法,减少攻击面,也减少编译时间。enable-ktls开启内核 TLS 卸载,对高吞吐的 sftp 场景有实际收益,不开也不影响功能。
make install_sw而不是make install,是因为后者会尝试装 man 手册和文档,在 DESTDIR 下容易出权限问题,而且打包时文档单独处理更干净。DESTDIR指向 BUILDROOT 下的暂存目录,这样产物不会真的装进/opt,而是留在暂存区等 rpm 打包收走。
这里有个容易忽略的细节:DESTDIR路径不要带尾斜杠,也不要用相对路径,否则%files里的路径会对不上。
4.2 OpenSSH 10.0p1 的 configure 参数逐项说明
OpenSSH 的 configure 参数比 OpenSSL 更需要斟酌,因为它直接决定了 sshd 能不能在目标机上正常启动。下面这套参数是在 Rocky 9.5 上验证过的组合。
# OpenSSH 10.0p1 构建脚本核心段 cd /data/rpmbuild/BUILD tar xf ../SOURCES/openssh-10.0p1.tar.gz cd openssh-10.0p1 ./configure \ --prefix=/usr \ --sysconfdir=/etc/ssh \ --libdir=/usr/lib64 \ --with-ssl-dir=/opt/openssl35 \ --with-pam \ --with-selinux \ --with-audit \ --with-privsep-path=/var/empty/sshd \ --with-default-path=/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin \ --with-kerberos5 \ --without-openssl-header-check \ LDFLAGS="-Wl,-rpath,/opt/openssl35/lib64" make -j"$(nproc)"--prefix=/usr而不是/usr/local,这是为了让二进制落在/usr/sbin/sshd,和系统 systemd unit 的ExecStart对上。如果 prefix 写成/usr/local,sshd.service找不到新二进制,会出现"装完了但版本没变"的经典误判。
--sysconfdir=/etc/ssh保证配置文件路径和系统一致,升级时/etc/ssh/sshd_config会被%config(noreplace)保护,本地修改不丢。
--with-ssl-dir=/opt/openssl35告诉 configure 去哪找 OpenSSL 的头文件和库。这里的判断逻辑是:configure 先看$withval/include有没有openssl/opensslv.h,再看$withval/lib64有没有libcrypto.so或libcrypto.a。如果两边都有,它可能优先静态链接libcrypto.a,产物会大一圈但部署更省心。脚本里的做法是保留.so,靠 rpath 定位,这样 OpenSSL 出补丁时只需要替换/opt/openssl35/lib64下的库文件,不用重编 OpenSSH。
--with-pam必须开。Rocky 上sshd的会话建立走 PAM,/etc/pam.d/sshd里的pam_nologin、pam_limits都依赖这个。关掉之后你会发现ulimit限制不生效、登录 shell 环境变量缺失,排查起来很费劲。
--with-privsep-path=/var/empty/sshd是权限分离用的空目录。这个目录的属主必须是 root,权限 755,且不能有任何内容。如果写成别的路径,%post里要记得建目录并设权,否则sshd启动直接报Privilege separation user sshd does not exist或者权限错误。
--with-default-path决定非登录 shell 场景下的PATH。默认值可能不含/usr/local/bin,某些运维脚本依赖这个路径,改了会出问题。
--without-openssl-header-check是给自定义前缀准备的。configure 默认会检查 OpenSSL 头文件版本和库版本是否匹配,跨前缀场景下这个检查偶尔误报,关掉它避免构建中断。
LDFLAGS里的 rpath 和 OpenSSL 那段一致,保证 sshd 运行时能找到libcrypto.so.3。这里要注意:如果两个库的 soname 相同(都是libcrypto.so.3),rpath 优先级高于/lib64,所以 sshd 用的是 3.5.0 的库,而系统其他程序仍然用 3.0.x 的,互不干扰。
4.3 spec 文件里最容易写错的四段
spec 是一个 RPM 的骨架,写错一段整个包就废。下面挑四处最容易出问题的展开。
# openssh.spec 头部与依赖段(节选) Name: openssh Version: 10.0p1 Release: 1.el9 Summary: OpenSSH 10.0p1 rebuilt against OpenSSL 3.5.0 License: BSD URL: https://www.openssh.com/ Source0: openssh-%{version}.tar.gz BuildRequires: gcc make zlib-devel pam-devel libselinux-devel audit-libs-devel krb5-devel Requires: openssl35-libs >= 3.5.0 Provides: openssh-server = %{version}-%{release} Obsoletes: openssh-server < %{version} AutoReqProv: yes %global __requires_exclude ^libcrypto\\.so\\.3\\(OPENSSL_3\\.0\\.0\\)\\(64bit\\)$Version: 10.0p1这种带字母的版本号 RPM 是接受的,但排序规则里字母大于数字,所以10.0p1和10.0比较时前者更大,符合预期。Release: 1.el9里的el9是给后续dnf判断用的,别省。
Requires: openssl35-libs是显式声明,因为自定义前缀的 OpenSSL 不走系统自动依赖解析,必须手写。Obsoletes: openssh-server < %{version}让新包能替换掉系统自带的 8.7p1 系列,dnf遇到时会走升级而不是冲突。
__requires_exclude那行是给后面避坑章节埋的伏笔:自建的 openssl35 包如果不排除自动 provides/requires,会和系统的openssl-libs抢同一个符号名,导致dnf依赖解析失败。这行正则只排除特定符号,不影响其他依赖。
# %install 段:把编译产物收进 buildroot %install rm -rf %{buildroot} make install DESTDIR=%{buildroot} # 手工补上特权分离目录,make install 不会自动建 install -d -m 0755 %{buildroot}/var/empty/sshd # 删掉不该进包的文件 rm -f %{buildroot}/usr/lib64/libssh.a find %{buildroot} -name '*.la' -deletemake install DESTDIR=%{buildroot}是把 4.2 编出来的东西按 prefix 铺到暂存区。install -d补/var/empty/sshd,这个目录在 4.2 里只配了路径,实际创建得靠打包段。rm -f删静态库和.la文件,前者没必要随包发布,后者在 RHEL 系上已经是历史遗留,留着会引来rpmlint警告。
# %post 段:升级后的必要动作 %post server # 主机密钥不存在时生成,已存在则跳过 /usr/bin/ssh-keygen -A >/dev/null 2>&1 || : # 权限分离目录兜底 [ -d /var/empty/sshd ] || install -d -m 0755 -o root -g root /var/empty/sshd # SELinux 上下文重打标(仅在 enforcing 时有效) if [ -x /sbin/restorecon ]; then /sbin/restorecon -R /etc/ssh /var/empty/sshd >/dev/null 2>&1 || : fi exit 0ssh-keygen -A是关键,它按/etc/ssh/sshd_config里默认的密钥类型补齐缺失的主机密钥。升级场景下旧密钥通常还在,这条是兜底。restorecon处理 SELinux 上下文,从 BUILDROOT 里带出来的文件上下文往往是default_t而不是sshd_key_t,不打标就会出AVC denied。exit 0必须写在最后,%post里任何一条命令返回非零都会让 rpm 事务标记为失败。
# %files 段:文件清单与配置保护 %files server %attr(0600,root,root) %config(noreplace) /etc/ssh/sshd_config %attr(0644,root,root) %config(noreplace) /etc/ssh/ssh_config %dir %attr(0755,root,root) /var/empty/sshd %attr(0755,root,root) /usr/sbin/sshd %attr(0755,root,root) /usr/bin/ssh %attr(0755,root,root) /usr/bin/ssh-keygen %attr(0755,root,root) /usr/bin/scp %attr(0755,root,root) /usr/bin/sftp %{_unitdir}/sshd.service %{_unitdir}/sshd.socket %{_unitdir}/sshd@.service%config(noreplace)是配置文件的护身符。升级时如果/etc/ssh/sshd_config被本地改过,rpm 不会覆盖,而是把新版本存成sshd_config.rpmnew,管理员自己决定怎么合。这个机制的前提是文件内容在包和本地确实不同,如果完全一致就不生成.rpmnew。
%dir %attr(0755,root,root) /var/empty/sshd单独声明目录并设权。目录权限 755 是对的,不能用 700,否则特权分离的 sshd 子进程切到sshd用户后进不去。
%{_unitdir}是 rpm 宏,展开成/usr/lib/systemd/system。三个 unit 都要打进去:sshd.service是主服务,sshd.socket支持 socket 激活,sshd@.service是模板单元。
4.4 出包与安装验证
spec 写完后执行构建。第一次跑必然有报错,正常,按提示改。
# 完整构建,产出二进制包和源码包 cd /data/rpmbuild/SPECS rpmbuild -ba openssh.spec 2>&1 | tee /tmp/build-openssh.log # 检查产物 ls -lh /data/rpmbuild/RPMS/x86_64/ rpm -qip /data/rpmbuild/RPMS/x86_64/openssh-10.0p1-1.el9.x86_64.rpm rpm -qlp /data/rpmbuild/RPMS/x86_64/openssh-server-10.0p1-1.el9.x86_64.rpm | head -30-ba表示既构建二进制包也构建源码包,源码包留着做归档,以后要复现直接rpmbuild --rebuild。2>&1 | tee把完整日志落盘,出错时往上翻几百行找error:关键字,比在终端里翻屏靠谱。
rpm -qip看包头信息,重点确认Version、Release、Requires三项。rpm -qlp列文件清单,重点看/usr/sbin/sshd在不在、/etc/ssh/sshd_config的%config标记有没有丢。
装之前先做一次依赖检查,不实际安装:
# 试装检查,不真正落地 dnf install --assumeno /data/rpmbuild/RPMS/x86_64/openssh-*.rpm--assumeno让 dnf 走到确认那一步就停,输出里能看到会装哪些包、会删哪些包、有没有冲突。这一步能提前发现"要卸载 openssh-clients 才能装"这类问题,避免在现场手忙脚乱。
真正安装后立刻验证三件事:版本、配置语法、服务状态。
# 安装后三项验证 rpm -q openssh openssh-server /usr/sbin/sshd -t && echo "config syntax OK" systemctl status sshd --no-pager -l | head -15 ssh -Vsshd -t只做语法检查不启动服务,返回 0 说明配置能被解析。这一步必须在重启服务之前做,因为配置写错会导致 sshd 起不来,而 sshd 起不来意味着你进不去机器。
5. 升级现场的五条避坑记录:从连不上到自己把自己锁死
5.1 坑一:sshd 重启成功,新连接全部被拒
现象:systemctl restart sshd返回 0,systemctl status显示 active (running),但新开一个终端连,直接Connection closed by remote host,老会话还活着。
原因通常是sshd_config里有一项在 10.0p1 下不再被接受,但sshd -t没报错是因为这项用了别名或已废弃但仍解析。更常见的是AuthorizedKeysFile或AllowUsers这类路径/用户匹配问题,老版本宽松,新版本严格。还有一种:Subsystem sftp的路径指向/usr/libexec/openssh/sftp-server,而新包把它装到了/usr/libexec/openssh/sftp-server(路径一般不变),但如果本地配置改过,就找不到。
解决:升级前先在旧版本上跑一遍sshd -T,把输出存成基线文件;装完新包后用sshd -T再跑一遍,diff两个文件。差异项逐条确认,特别是kexalgorithms、ciphers、macs、pubkeyacceptedalgorithms这四项,新旧版本的默认值必然不同,配置里如果写死了旧算法名,新版本可能直接不认。
血泪经验是:一定要留一个已经登录的会话别关。所有操作在这个会话里做,新开窗口验证,验证通过再关老会话。这套流程听起来啰嗦,但它是唯一能在配置写错时救命的办法。
5.2 坑二:老设备与老客户端握手失败
现象:升级后运维用的跳板机、网络设备的自动化备份脚本、某台老打印机的定时上报,全部连不上,日志里是no matching key exchange method found或no matching host key type found。
原因很直接:10.0p1 的默认算法集砍掉了老算法,而对面只支持diffie-hellman-group14-sha1、ssh-rsa(SHA-1)这类。这不是 bug,是设计。新版本的立场是"不安全的东西默认不给用"。
解决有两条路。第一条是对客户端做兼容白名单,在sshd_config里为特定网段开一个Match块:
# 仅为存量设备网段放行老算法,其他来源仍走严格基线 Match Address 10.20.0.0/16 KexAlgorithms +diffie-hellman-group14-sha1 HostKeyAlgorithms +ssh-rsa PubkeyAcceptedAlgorithms +ssh-rsaMatch Address限定生效范围,+表示在默认列表基础上追加而不是替换。这样既满足了存量设备,又不至于对全互联网放开。注意HostKeyAlgorithms和PubkeyAcceptedAlgorithms要同时加,前者管主机密钥协商,后者管用户公钥认证,少一个都连不上。
第二条路是推动对面升级。现实里通常推不动,所以第一条是主力方案。但要在基线文档里记一笔:这些Match块是技术债,每季度 review 一次,能删就删。
5.3 坑三:把 openssl35 的库路径写进 ld.so.conf,dnf 当场罢工
现象:为了图省事,往/etc/ld.so.conf.d/openssl35.conf写了一行/opt/openssl35/lib64,ldconfig一跑,dnf、curl、rpm全部报symbol lookup error或段错误。
原因是系统自带的libcrypto.so.3是 OpenSSL 3.0.x 编的,ld.so.conf把/opt/openssl35/lib64排到前面后,所有动态链接libcrypto.so.3的程序都改去加载 3.5.0 的库。soname 一样,但内部符号版本和数据结构变了,dnf依赖的 Python 绑定直接崩。
解决:永远不要在ld.so.conf.d里加自定义 OpenSSL 路径。正确做法是给需要它的二进制单独加 rpath,也就是 4.1 和 4.2 里-Wl,-rpath,/opt/openssl35/lib64的作用。rpath 是 per-binary 的,只影响sshd、ssh这几个程序,不影响系统其他部分。
已经踩进去的恢复办法:rm /etc/ld.so.conf.d/openssl35.conf && ldconfig,然后dnf reinstall openssl-libs。如果dnf已经起不来,用LD_PRELOAD=/lib64/libcrypto.so.3 dnf ...或者进救援模式。这个坑的代价可能是几十分钟的业务中断,值得单独记一笔。
5.4 坑四:主机密钥指纹变了,自动化脚本全部认证失败
现象:升级后首次连接提示REMOTE HOST IDENTIFICATION HAS CHANGED,known_hosts里的指纹对不上。更麻烦的是某些用主机密钥做二次校验的自动化流程直接失败。
原因一般是%post里的ssh-keygen -A在旧密钥文件权限不对、或者文件被%config处理成.rpmsave的情况下重新生成了一套新密钥。也可能 spec 里%files漏了/etc/ssh/ssh_host_*,导致升级时旧密钥被当成本地文件保留但新包没声明,%post判断"不存在"就重建了。
解决:升级前先备份/etc/ssh/ssh_host_*到/root/ssh-hostkeys-backup/,权限 600。升级后diff一下新旧密钥的指纹,确认没变。如果确实变了,把备份恢复回去,restorecon重打标,重启sshd。
ssh-keygen -A的行为是"缺哪个补哪个",已有文件不会覆盖。所以正常情况下指纹不会变。会变的场景基本都是文件权限或 SELinux 上下文不对,导致ssh-keygen认为文件不可用。防范手段是升级前跑一次ls -lZ /etc/ssh/ssh_host_*,把权限和上下文记下来。
5.5 坑五:SELinux 上下文不对,sshd 起不来但日志只有一行
现象:systemctl start sshd失败,journalctl -u sshd只有error: Bind to port 22 on 0.0.0.0 failed: Permission denied或者更模糊的sshd: error: setresuid。
原因是%post里的restorecon没跑成功,/var/empty/sshd的上下文是default_t而不是sshd_privsep_t,或者/etc/ssh/ssh_host_*_key的上下文不是sshd_key_t。SELinux 在 enforcing 模式下直接拒绝访问,表现出来的错误信息往往指向不到真正原因。
解决:先确认模式,getenforce返回Enforcing的话,用ausearch -m AVC -ts recent看最近的拒绝记录,输出里的scontext、tcontext、tclass三项直接告诉你哪个文件上下文错了。然后restorecon -Rv /etc/ssh /var/empty/sshd重打标。
根治办法是在 spec 的%post里写死restorecon并加|| :兜底,同时保证%files里声明的路径和实际一致。还有一个细节:/var/empty/sshd这个目录如果用%dir声明,rpm 会按%attr设权,但不会自动打 SELinux 标,必须靠restorecon。
预防手段是在构建机上装policycoreutils-python-utils,用semanage fcontext -l | grep ssh查标准上下文规则,和包里的文件路径逐条对照。这套检查一次做完,后面每次打包都能复用。
6. 加固参数与灰度验证:把 10.0p1 收敛到可审计基线
版本升上去只是第一步,真正决定合规结论的是sshd_config怎么配。下面这张表是我在多个项目里收敛出来的基线,列出的项在 10.0p1 下都验证可用,配置里写不认识的项会被sshd -t直接拒绝。
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| Protocol | 2 | 只保留 SSH-2 协议 |
| PermitRootLogin | prohibit-password | 允许密钥不允许密码 |
| MaxAuthTries | 4 | 降低暴力破解窗口 |
| LoginGraceTime | 30 | 缩短未认证连接占用 |
| ClientAliveInterval | 300 | 保活与死连接回收 |
| ClientAliveCountMax | 2 | 与上一条配合 |
| PermitEmptyPasswords | no | 空密码一律拒绝 |
| X11Forwarding | no | 非必要不开 |
| AllowTcpForwarding | no | 按需放开,默认关 |
| PermitTunnel | no | 禁止 tun 设备转发 |
| UsePAM | yes | Rocky 上必须开 |
| LogLevel | VERBOSE | 留足审计日志 |
| Banner | /etc/ssh/banner | 登录提示,合规常用 |
算法集那几项不要照抄网上的"最强列表",要看ssh -Q kex、ssh -Q cipher、ssh -Q mac在当前版本下的实际输出,从中挑同时满足基线和客户端兼容的子集。写死的算法名一旦在新版本里被移除,sshd -t就过不去,这是升级时最常见的配置返工点。
验证不能只看ssh -V。我一般跑一段组合脚本:
# 版本、配置、算法、监听四项一次看完 sshd -V 2>&1 | head -3 sshd -T | grep -E '^(permitrootlogin|maxauthtries|logingracetime|usepam|x11forwarding) ' ssh -Q kex | tr '\n' ' ' | fold -w 100 ss -lntp | grep ':22 'sshd -V输出的版本号要和新包一致,不一致说明跑的二进制还是旧的,回去查ExecStart路径。sshd -T会把最终生效配置全部打印出来,grep挑关键项确认。ssh -Q列出当前版本支持的全部算法,和sshd_config里写死的列表做交集,确认没有拼写错误。ss确认监听端口和进程。
灰度这块,我的做法是分三批:第一批一台非核心机器,跑满 24 小时,观察journalctl -u sshd里有没有异常断连、认证失败突增;第二批同网段五到十台,重点验证自动化脚本和监控探针能不能连上;第三批全量。每批之间保留 48 小时观察窗,且每批升级前都留一份rpm -qa | grep openssh的输出和旧包文件本体。
回滚路径要提前验证一次,不能等出事才试。dnf downgrade openssh openssh-server或者直接rpm -Uvh --oldpackage旧包,回滚后立刻sshd -t加systemctl restart sshd,确认能登录。这条路径我要求在灰度第一批时就跑一遍,因为回滚本身也会失败,早点知道总比深夜发现强。
最后说个习惯。从那以后我每次动 SSH,都强制走一遍"老会话挂着、配置先 diff、回滚先演练"这三步,不管多急。这套动作看着慢,但它把"把自己锁在门外"的概率压到了接近零。这套脚本和 spec 我放在项目归档里,机器数量一多,直接rpmbuild --rebuild出包,剩下的就是排批次了。希望帮到你。
本文还有配套的精品资源,点击获取