记一次定时任务迁移后 SSH 集体失败的排查
事情是怎么开始的
线上有个 PHP 写的监控脚本,逻辑不复杂:SSH 到几台中转节点上 cat 个日志、find 个文件,对比一下版本号,不一致就发告警。它在老服务器上跑了很久,一直没事。
最近把 cron 迁到新服务器,告警就开始刷屏了。日志里大概长这样:
1 | use ssh exec cmd failed | cmd:/usr/bin/ssh -i ~/.ssh/id_rsa user@scandb31 "cat /usr/local/www/dmproxy/log/latest_downloaded_fileno_vmave.log" | output:<> | result_code:1 |
然后跟着一堆”中转节点中的扫描器 MD5 不是当前最新的版本””Inc 不是当前最新的版本”,每台节点三条,看着像同步服务全挂了。
老服务器上同一时间跑得好好的,脚本对比过 md5,完全一样。
第一反应:又是 cron 环境那点事
“手动正常、cron 报错”,这个组合太经典了,八成是环境变量。新服务器上 cron 的环境确实和交互 shell 不一样,于是我把 crontab 的环境照着交互环境配了一遍。
没用,还是报。
这时候我停下来重新看了眼报错,注意到一个之前忽略的细节:result_code:1。
SSH 的退出码是有讲究的:连不上、认证失败、host key 不对,这类问题返回 255;返回 1,说明 SSH 本身连上了、认证也过了,是远端那条命令自己退出码是 1。而且 output 是空的——连 cat 都 cat 不出任何东西。
也就是说,前面折腾的环境变量、密钥,方向可能从一开始就不对。问题出在远端执行这一环。
误报的告警
回头去读脚本代码,发现那些”MD5 不是最新版本”的告警根本不可信。
脚本里 SSH 执行的封装长这样:命令没输出就记条日志,返回 FALSE。但调用方拿到 FALSE 之后,直接当字符串去 explode 解析:
1 | $result = $this->sshCmd($host, $cmd); // 失败时 = FALSE |
null 和最新版本比对,当然不相等,于是”MD5 不是最新版本”就出来了。真正的问题只有一个——SSH 执行失败,其他全是它带出来的连锁误报。
还有个更麻烦的事:PHP 的 exec() 只收 stdout,stderr 直接扔了。SSH 和远端命令的报错恰恰都在 stderr 里。等于说脚本把所有有价值的线索都吞了,只给我留了一个干巴巴的 exit code。这次排查费这么大劲,一半”功劳”归它。
最小化复现
既然怀疑和环境有关,先做个能稳定复现的用例:
1 | # 交互 shell 下,一切正常 |
注意第二条:连 echo ok 都没有任何输出。echo 是不可能失败的,所以远端根本没执行到这条命令,会话在更早的地方就被掐断了。
各跑了三遍,交互三次全成功,最小环境三次全失败。确定性故障,不是网络抖动之类的随机问题。顺手也排除了几个嫌疑:域名只有一条 A 记录,SSH_AUTH_SOCK 是空的(没有 agent,两种场景用的是同一把密钥)。
排除法卡住了
接下来是笨办法:把环境变量一个个加回最小环境里试。TERM、LANG、USER、LOGNAME、SHELL、PWD……挨个试过去,全都还是 exit=1。
单变量排除失败了。要么是组合差异,要么是某个我压根没想到的变量。
日志 diff
想了个直接点的办法:把成功和失败两种场景都用 ssh -v 跑一遍,拿详细日志做 diff。
1 | > debug1: Sending env sec_ssh_token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... |
成功的会话比失败的会话多发了两个环境变量,其中一个叫 sec_ssh_token,值是个 JWT。
查了下 /etc/ssh/ssh_config,里面有一行:
1 | SendEnv LANG LC_CTYPE ... sec_ssh_token |
到这一步机制基本清楚了。这是公司的安全 SSH 网关机制(远端 authorized_keys 里那个 360SEC-AUTO 的签名密钥也对得上):
- 登录的时候,
/etc/profile.d/sec_ssh_check.sh会生成一个 JWT(有效期大概 24 小时)注入到会话环境里; - SSH 客户端通过
SendEnv把它带给远端; - 远端校验这个 token,没有或者无效,会话直接被静默掐断——认证照样显示成功,但命令零输出、exit 1。
cron 环境永远不会执行 profile,所以永远拿不到这个 token。
这个 token 是怎么进到 ssh 里的
搞清楚机制之后,我顺手研究了一个问题:安全部是怎么把 token “塞进” SSH 会话的?答案是——ssh 二进制没被替换,命令行也没被改写,用的是 SSH 协议原生的环境变量传递机制(SendEnv/AcceptEnv)。
1 | 登录新服务器 |
SendEnv 本来是传 LANG、LC_* 这类 locale 变量用的,往匹配列表里加一个自定义变量名,客户端就会照常把它发给远端。所以安全部做的三件事全在配置层:客户端的 ssh_config 里加 SendEnv,登录侧用 profile 脚本注入 token,远端部署校验逻辑。
回头看,这也是这套方案”放过交互、掐死 cron”的根本原因:token 只在登录时注入,而 crond 不走登录流程、不加载 profile,cron 环境里永远不会有这个变量。这类零信任接入方案天然兼容人的登录(登录即领 token),但机器场景——cron、脚本、CI——天然拿不到 token,只能走专门的豁免通道。
后来在目标机器上把这个脚本完整 cat 了出来,token 的签发逻辑算是落了地:
1 | FILE="/etc/profile.d/SSHBlocker" |
(原文里有信息安全部的联系邮箱,这里略去。)
几个细节值得说。token 的”签发”就是本地执行 SSHBlocker 这个二进制,它的 stdout 是什么,会话里的 token 就是什么。SSHBlocker 内部还会判断登录来源,来自非堡垒机的登录直接拒绝——打印提示后 exit 1,把登录踢掉。所以 token 本质上是一张凭证,证明”本次会话是从堡垒机认证登录进来的”。sec_ssh_isfirst 是一次性开关,防止嵌套 shell 重复执行;token 和它都被设成 readonly,会话内改不了。
这也回头印证了一件事:我中途设想过”在 cron 里 source 这个脚本拿 token”的曲线救国方案,现在看是死路——cron 会话不是从堡垒机登录来的,SSHBlocker 不会给它发 token,否则这套管控就形同虚设了。公共账号确实是唯一正解。
至此只剩一个细节没有实证:远端对 ssh 命令会话的”静默掐断”挂在哪一层。如果拦截方是这个脚本,失败时应该能看到那行”认证失败”的提示,而我们实际观察到的失败是零输出——所以更像是 sshd 或 PAM 层的行为。不影响结论,留个悬念。
那老服务器为什么没事?
这是最后一个没闭合的环节。在老服务器上加了个临时 cron 任务,把真实环境 dump 出来看:
1 | SHELL=/bin/sh |
没有 token,环境也是标准的 cron 最小环境。但老服务器上用 env -i 跑同样的 SSH,就是能成功。
所以结论是:这个 token 校验是按来源主机灰度覆盖的,新服务器已经被纳入管控,老服务器还没有。老服务器一直”正常”,不是它配置做得对,只是还没被管到而已。想想还有点后怕——如果哪天灰度推到老服务器,那边也会悄无声息地挂掉。
解决
问了安全部,跨服务器远程登录按规范要用公共账号,公共账号不走这套 token 校验(安全部已经测过)。把脚本里 SSH 的远程用户改成公共账号,问题解决。
顺带把脚本本身的几个坑也填了:
exec()改成带2>&1,stderr 不再被吞;- SSH 失败时明确报”节点 SSH 执行失败”,不再掉进版本比对的逻辑里变成误导性告警;
- SSH 加上
-o BatchMode=yes -o ConnectTimeout=10,免得 cron 场景下卡在什么交互提示上。
几点感受
退出码值得认真读。255 和 1 指向完全不同的故障域,这次要是早点注意到是 1 而不是 255,能少走不少弯路。
报错信息别丢。exec() 这类接口默认只给 stdout,而失败的线索往往在 stderr。一个 2>&1 的事,却让排查难度翻了好几倍。
“手动正常、cron 报错”的排查套路这次算是走全了:env -i 最小化复现,确认确定性,单变量二分,最后 ssh -v 日志 diff。前面几步都碰壁的时候,是 diff 把差异直接摆在了脸上——两个几乎一模一样的日志,多出来的那一行就是答案。
还有一点:老环境”一直没出过问题”,不等于它是对的。灰度推的安全策略、还没触发的前提条件,都可能让旧环境暂时享受豁免。对比两边的差异之前,先确认它们面对的约束是不是一样的。