故障排查——安全网关token校验致cron下ssh静默失败

cuixiaogang

根因:公司安全 SSH 网关要求会话携带「经堡垒机认证登录」时签发的 JWT(sec_ssh_token),由客户端按 SendEnv 转发、远端校验;cron 不走登录流程、永远拿不到 token,远端静默掐断会话(认证成功、零输出、exit 1)。脚本迁到新服务器恰逢其被纳入管控灰度(老服务器尚未),同一脚本因此一边报错刷屏、一边「运行正常」。

现象

一个 PHP 监控脚本:SSH 到几台中转节点 cat 日志、find 文件,对比版本号,不一致发告警。在老服务器上跑了很久一直无事;cron 迁到新服务器后告警刷屏:

1
use ssh exec cmd failed | cmd:/usr/bin/ssh -i ~/.ssh/id_rsa <用户>@<中转节点> "cat /usr/local/www/<服务>/log/<文件>.log" | output:<> | result_code:1

随后每台节点跟着三条「MD5 不是当前最新」「Inc 不是当前最新」,像同步服务全挂了。老服务器同一时刻运行正常,两边脚本 md5 一致。

环境

  • 新、老服务器都跑同一 cron 脚本;cron 环境与交互 shell 天然不同。
  • 主机侧存在安全 SSH 网关:登录时签发 JWT 注入会话环境,ssh 客户端经 SendEnv 带给远端,远端校验(机制详见「根因」)。
  • 现象层面注意:报错只有干巴巴的 result_code:1,无 stderr——线索先天残缺(见排查过程第二节)。

排查过程

第一步:疑心 cron 环境变量——退出码把方向掰回来

「手动正常、cron 报错」是经典组合,第一反应照旧:把 crontab 环境按交互 shell 配齐。无效。停下来重读报错,注意到 result_code:1:

  • SSH 连不上、认证失败、host key 不符这类问题退出码是 255;
  • 返回 1 说明 SSH 已连通、认证已通过,是远端命令自身以 1 退出;
  • 且 output:<> 为空——连 cat 都无输出。

推断:环境变量与密钥方向从一开始就不对,问题在远端执行环节。(此弯路改变了排查方向,保留。)

第二步:读脚本——拆开真问题与连锁误报

那些「版本不是最新」的告警根本不可信。SSH 执行封装在失败时返回 FALSE,调用方却直接当字符串解析:

1
2
3
4
$result = $this->sshCmd($host, $cmd);   // 失败时 = FALSE
$result = explode("/", $result); // FALSE → [""]
$md5 = $result[2]; // null
$inc = $result[1]; // null

null 与最新版本比对必然不等——每台节点三条告警只是「SSH 执行失败」的连锁误报,真问题只有一个。

同时确认线索残缺的成因:PHP exec() 只收 stdout,而 SSH 与远端命令的报错都在 stderr——脚本把最有价值的线索吞了。排查费力的根源之一在此(修复动作归入「解决」)。

第三步:最小化复现——确认确定性故障

1
2
3
4
5
6
7
8
# 交互 shell 下,一切正常
ssh -i ~/.ssh/id_rsa <用户>@<中转节点> "cat ...log"; echo $?
10000012663
exit=0

# 清空环境再来
env -i HOME=<用户目录> PATH=/usr/bin:/bin ssh -i ~/.ssh/id_rsa <用户>@<中转节点> "echo ok"; echo $?
exit=1

第二条里连 echo ok 都零输出——echo 不可能失败,说明远端根本没执行到命令,会话在更早处被掐断。各跑三遍:交互三次全成、最小环境三次全败,确定性故障,排除网络抖动。顺手排除两个嫌疑:域名仅一条 A 记录(无解析差异);SSH_AUTH_SOCK 为空(无 agent,两种场景同一把密钥)。

第四步:单变量二分——全部证伪

把环境变量逐个加回最小环境:TERM、LANG、USER、LOGNAME、SHELL、PWD……仍是 exit=1。

结论:不是单变量差异。要么组合、要么某个完全没想到的变量——排除法到头,换手段。

第五步:ssh -v 日志 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

机制浮现。远端 authorized_keys 里带安全网关签名的密钥注释也对得上——这是公司安全 SSH 网关的一部分。

第六步:验证 token 链路——并堵死一条曲线救国路

完整机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
登录新服务器
▼
① /etc/profile.d/sec_ssh_check.sh 执行(登录时 /etc/profile 加载)
│ 生成 JWT(有效期约 24 小时),export sec_ssh_token=eyJ...
▼
② 会话内执行 ssh <用户>@<中转节点> "cmd"
│ ssh 进程继承会话环境,自带 sec_ssh_token
▼
③ ssh 客户端按 ssh_config 的 SendEnv 匹配挑选变量,会话建立后发给远端
│ (ssh -v 的 "Sending env ..." 即此步调试输出)
▼
④ 远端 sshd 按 AcceptEnv 接收,安全校验组件验 JWT 签名与有效期
├── 有效 → 放行,正常执行命令
└── 缺失/无效 → 静默掐断会话(认证成功、零输出、exit 1)

cron 不执行 profile ⇒ 永远没有 token。后续把签发脚本完整取出,机制落地:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;SSHBlocker 内部校验登录来源,非堡垒机登录直接拒绝并踢出——所以 token 本质是一张「本次会话经堡垒机认证登录」的凭证。sec_ssh_isfirst 防嵌套 shell 重复签发,两个变量均 readonly 会话内改不动。

由此排除一条弯路:在 cron 里 source 该脚本骗个 token——死路。cron 会话并非堡垒机登录而来,SSHBlocker 不会为其签发,否则管控形同虚设。

遗留一处存疑(如实记录):「静默掐断」究竟挂在哪一层。若拦截发生在此脚本,失败时应能看到「认证失败」提示,而实测零输出——更像 sshd 或 PAM 层行为。不影响结论。

第七步:老服务器为何没事——dump 环境对照,锁定灰度

最后未闭合的环节。在老服务器加临时 cron 任务 dump 真实环境:

1
2
SHELL=/bin/sh
PATH=/usr/bin:/bin

标准 cron 最小环境,同样没有 token;但在老服务器上 env -i 跑同样的 SSH 能成功。

结论:token 校验按来源主机灰度覆盖——新服务器已纳入管控,老服务器还没有。老环境一直「正常」不是配置正确,只是还没被管到;灰度推过去的那天,它也会同样悄无声息地挂掉。

根因

  • 一句话:安全 SSH 网关的访问凭证只在「堡垒机认证登录」时经 profile 注入会话,cron 场景结构上拿不到 token,远端校验失败后静默掐断命令执行。
  • 机制拆解:SendEnv/AcceptEnv 是 SSH 协议原生的环境变量传递;登录侧脚本签发 JWT → 客户端按配置转发 → 远端验签。校验发生在认证通过之后、命令执行之前,所以表现为「认证成功 + 零输出 + exit 1」而非 255。
  • 为什么此时才爆发:新服务器刚被纳入灰度;老服务器未纳入,故同脚本两重天。「老环境没出问题」不等于「老环境符合约束」。
  • 为什么难查:失败线索被脚本自己吞掉(exec() 丢 stderr),只剩一个易误读的退出码;外加版本比对的连锁误报放大噪音。

解决

问得安全部规范:跨服务器机器场景的远程登录应使用公共账号,公共账号不走该 token 校验(安全部已实测)。把脚本中 SSH 的远程用户改为公共账号,故障消除。同时修掉脚本自身三个坑:

1
2
3
4
5
6
7
8
# 1. 不再吞 stderr
exec($cmd, $out, $code) 的调用处改为命令尾部追加 2>&1

# 2. cron 场景防卡交互提示
ssh -o BatchMode=yes -o ConnectTimeout=10 ...

# 3. SSH 失败即明确报「节点 SSH 执行失败」,短路返回,
# 不再掉进版本比对逻辑产生误导性告警

防复发

  • 老服务器灰度迟早覆盖:提前把老服务器上的同类脚本也切公共账号,别等告警无声刷屏(待办)。
  • 新增机器场景的自动化任务:默认按公共账号/豁免通道规划,不依赖任何登录时注入的凭证。
  • 零信任类管控上线时:机器场景(cron、脚本、CI)是天然盲区,接入前主动排查存量任务。

关键知识点总结

SSH 故障定位

  • 退出码语义:255 = 连接、认证、host key 层面;1 = 已连通、远端命令自身失败——两种「SSH 失败」的排查域完全不同,读退出码能省一半路。
  • 「认证成功」不等于「命令执行」:认证之后的策略层(sshd/PAM/安全组件)可以静默掐断,表现为认证成功 + 零输出 + exit 1。
  • cron/脚本场景三件套:-o BatchMode=yes(禁交互提示)、-o ConnectTimeout=N(限时)、stderr 重定向入日志。
  • 两场景几乎一样却一成一败时,ssh -v 日志 diff 是终极大法:多出来的那一行就是答案;比逐个变量二分收敛快得多。

cron 环境差异排查套路(「手动正常、cron 报错」通用路径)

  1. env -i 构造最小环境,复现并确认确定性(多跑几次排抖动);
  2. 单变量二分回加——全军覆没就放弃单变量假设;
  3. 成功/失败两条完整日志做 diff,差异摆脸上。
  • 另注意:cron 不加载 profile,一切「登录时注入」的机制(token、代理变量、公司安全策略)对 cron 结构性失效。

环境变量传递与登录态凭证类安全方案

  • SendEnv/AcceptEnv:SSH 协议原生环境变量传递;客户端按变量名列表挑选转发,远端接收注入。安全方案只动配置层即可实现「凭证随会话走」。
  • 以「堡垒机登录」为签发条件的 JWT 凭证:本地二进制取 stdout 即 token,readonly + 一次性开关防篡改防重复;来源校验决定「非登录态拿不到」。
  • 此类方案天然兼容人、不兼容机器;豁免通道(公共账号/服务账号)必须显式存在,机器场景接入前先问清。

PHP 脚本工程细节

  • exec() 只收 stdout,stderr 默认丢弃;集成外部命令必须 2>&1 或 proc_open 显式接管。
  • 「失败返回 FALSE、调用方当字符串继续解析」是连锁误报发生器:null 比对失败会把一个基础设施故障放大成 N 条业务告警。失败要短路报错,别让 FALSE 流进业务逻辑。