记一次定时任务迁移后 SSH 集体失败的排查

cuixiaogang

事情是怎么开始的

线上有个 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
2
3
4
$result = $this->sshCmd($host, $cmd);   // 失败时 = FALSE
$result = explode("/", $result); // FALSE → [""]
$md5 = $result_arr[2]; // null
$inc = $result_arr[1]; // null

null 和最新版本比对,当然不相等,于是”MD5 不是最新版本”就出来了。真正的问题只有一个——SSH 执行失败,其他全是它带出来的连锁误报。

还有个更麻烦的事:PHP 的 exec() 只收 stdout,stderr 直接扔了。SSH 和远端命令的报错恰恰都在 stderr 里。等于说脚本把所有有价值的线索都吞了,只给我留了一个干巴巴的 exit code。这次排查费这么大劲,一半”功劳”归它。

最小化复现

既然怀疑和环境有关,先做个能稳定复现的用例:

1
2
3
4
5
6
7
8
# 交互 shell 下,一切正常
ssh -i ~/.ssh/id_rsa user@scandb31 "cat ...log"; echo $?
10000012663
exit=0

# 清空环境再来
env -i HOME=/home/user PATH=/usr/bin:/bin ssh -i ~/.ssh/id_rsa user@scandb31 "echo ok"; echo $?
exit=1

注意第二条:连 echo ok 都没有任何输出。echo 是不可能失败的,所以远端根本没执行到这条命令,会话在更早的地方就被掐断了。

各跑了三遍,交互三次全成功,最小环境三次全失败。确定性故障,不是网络抖动之类的随机问题。顺手也排除了几个嫌疑:域名只有一条 A 记录,SSH_AUTH_SOCK 是空的(没有 agent,两种场景用的是同一把密钥)。

排除法卡住了

接下来是笨办法:把环境变量一个个加回最小环境里试。TERM、LANG、USER、LOGNAME、SHELL、PWD……挨个试过去,全都还是 exit=1。

单变量排除失败了。要么是组合差异,要么是某个我压根没想到的变量。

日志 diff

想了个直接点的办法:把成功和失败两种场景都用 ssh -v 跑一遍,拿详细日志做 diff。

1
2
> debug1: Sending env sec_ssh_token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
> debug1: Sending env LANG = en_US.UTF-8

成功的会话比失败的会话多发了两个环境变量,其中一个叫 sec_ssh_token,值是个 JWT。

查了下 /etc/ssh/ssh_config,里面有一行:

1
SendEnv LANG LC_CTYPE ... sec_ssh_token

到这一步机制基本清楚了。这是公司的安全 SSH 网关机制(远端 authorized_keys 里那个 360SEC-AUTO 的签名密钥也对得上):

  1. 登录的时候,/etc/profile.d/sec_ssh_check.sh 会生成一个 JWT(有效期大概 24 小时)注入到会话环境里;
  2. SSH 客户端通过 SendEnv 把它带给远端;
  3. 远端校验这个 token,没有或者无效,会话直接被静默掐断——认证照样显示成功,但命令零输出、exit 1。

cron 环境永远不会执行 profile,所以永远拿不到这个 token。

这个 token 是怎么进到 ssh 里的

搞清楚机制之后,我顺手研究了一个问题:安全部是怎么把 token “塞进” SSH 会话的?答案是——ssh 二进制没被替换,命令行也没被改写,用的是 SSH 协议原生的环境变量传递机制(SendEnv/AcceptEnv)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
登录新服务器


① /etc/profile.d/sec_ssh_check.sh 执行(登录时 /etc/profile 会加载它)
│ 生成 JWT,export sec_ssh_token=eyJ...

② 在会话里执行 ssh user@scandb31 "cmd"
│ ssh 进程继承会话环境,自然带上了 sec_ssh_token

③ ssh 客户端读取 /etc/ssh/ssh_config:
│ SendEnv LANG LC_CTYPE ... sec_ssh_token
│ 按匹配规则挑选环境变量,在会话建立后通过协议消息发给远端
│ —— ssh -v 里的 "Sending env sec_ssh_token = ..." 就是这一步的调试输出

④ 远端 sshd 通过 AcceptEnv 接收,注入远端会话环境
│ 安全校验组件验 JWT 的签名和有效期
├── 有效 → 放行,正常执行命令
└── 缺失/无效 → 静默掐断会话(认证成功、零输出、exit 1)

SendEnv 本来是传 LANGLC_* 这类 locale 变量用的,往匹配列表里加一个自定义变量名,客户端就会照常把它发给远端。所以安全部做的三件事全在配置层:客户端的 ssh_config 里加 SendEnv,登录侧用 profile 脚本注入 token,远端部署校验逻辑。

回头看,这也是这套方案”放过交互、掐死 cron”的根本原因:token 只在登录时注入,而 crond 不走登录流程、不加载 profile,cron 环境里永远不会有这个变量。这类零信任接入方案天然兼容人的登录(登录即领 token),但机器场景——cron、脚本、CI——天然拿不到 token,只能走专门的豁免通道。

后来在目标机器上把这个脚本完整 cat 了出来,token 的签发逻辑算是落了地:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
FILE="/etc/profile.d/SSHBlocker"
if [ ! -e "$FILE" ]; then
return
fi

if [ -z "$sec_ssh_isfirst" ]; then
export sec_ssh_isfirst="1"
readonly sec_ssh_isfirst
a=$($FILE) # 执行 SSHBlocker,stdout 就是 token
if [ "$a" == "access denied" ]; then
echo "认证失败,来自非堡垒机登录。"
exit 1
elif [ "$a" == "exit" ]; then
echo "认证失败。"
exit 1
else
export sec_ssh_token="$a"
readonly sec_ssh_token
fi
fi

(原文里有信息安全部的联系邮箱,这里略去。)

几个细节值得说。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
2
SHELL=/bin/sh
PATH=/usr/bin:/bin

没有 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 把差异直接摆在了脸上——两个几乎一模一样的日志,多出来的那一行就是答案。

还有一点:老环境”一直没出过问题”,不等于它是对的。灰度推的安全策略、还没触发的前提条件,都可能让旧环境暂时享受豁免。对比两边的差异之前,先确认它们面对的约束是不是一样的。