故障排查——安全网关token校验致cron下ssh静默失败
根因:公司安全 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 | $result = $this->sshCmd($host, $cmd); // 失败时 = FALSE |
null 与最新版本比对必然不等——每台节点三条告警只是「SSH 执行失败」的连锁误报,真问题只有一个。
同时确认线索残缺的成因:PHP exec() 只收 stdout,而 SSH 与远端命令的报错都在 stderr——脚本把最有价值的线索吞了。排查费力的根源之一在此(修复动作归入「解决」)。
第三步:最小化复现——确认确定性故障
1 | # 交互 shell 下,一切正常 |
第二条里连 echo ok 都零输出——echo 不可能失败,说明远端根本没执行到命令,会话在更早处被掐断。各跑三遍:交互三次全成、最小环境三次全败,确定性故障,排除网络抖动。顺手排除两个嫌疑:域名仅一条 A 记录(无解析差异);SSH_AUTH_SOCK 为空(无 agent,两种场景同一把密钥)。
第四步:单变量二分——全部证伪
把环境变量逐个加回最小环境:TERM、LANG、USER、LOGNAME、SHELL、PWD……仍是 exit=1。
结论:不是单变量差异。要么组合、要么某个完全没想到的变量——排除法到头,换手段。
第五步:ssh -v 日志 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 |
机制浮现。远端 authorized_keys 里带安全网关签名的密钥注释也对得上——这是公司安全 SSH 网关的一部分。
第六步:验证 token 链路——并堵死一条曲线救国路
完整机制:
1 | 登录新服务器 |
cron 不执行 profile ⇒ 永远没有 token。后续把签发脚本完整取出,机制落地:
1 | FILE="/etc/profile.d/SSHBlocker" |
要点:token 的「签发」就是本地执行 SSHBlocker 二进制取其 stdout;SSHBlocker 内部校验登录来源,非堡垒机登录直接拒绝并踢出——所以 token 本质是一张「本次会话经堡垒机认证登录」的凭证。sec_ssh_isfirst 防嵌套 shell 重复签发,两个变量均 readonly 会话内改不动。
由此排除一条弯路:在 cron 里 source 该脚本骗个 token——死路。cron 会话并非堡垒机登录而来,SSHBlocker 不会为其签发,否则管控形同虚设。
遗留一处存疑(如实记录):「静默掐断」究竟挂在哪一层。若拦截发生在此脚本,失败时应能看到「认证失败」提示,而实测零输出——更像 sshd 或 PAM 层行为。不影响结论。
第七步:老服务器为何没事——dump 环境对照,锁定灰度
最后未闭合的环节。在老服务器加临时 cron 任务 dump 真实环境:
1 | SHELL=/bin/sh |
标准 cron 最小环境,同样没有 token;但在老服务器上 env -i 跑同样的 SSH 能成功。
结论:token 校验按来源主机灰度覆盖——新服务器已纳入管控,老服务器还没有。老环境一直「正常」不是配置正确,只是还没被管到;灰度推过去的那天,它也会同样悄无声息地挂掉。
根因
- 一句话:安全 SSH 网关的访问凭证只在「堡垒机认证登录」时经 profile 注入会话,cron 场景结构上拿不到 token,远端校验失败后静默掐断命令执行。
- 机制拆解:
SendEnv/AcceptEnv是 SSH 协议原生的环境变量传递;登录侧脚本签发 JWT → 客户端按配置转发 → 远端验签。校验发生在认证通过之后、命令执行之前,所以表现为「认证成功 + 零输出 + exit 1」而非 255。 - 为什么此时才爆发:新服务器刚被纳入灰度;老服务器未纳入,故同脚本两重天。「老环境没出问题」不等于「老环境符合约束」。
- 为什么难查:失败线索被脚本自己吞掉(
exec()丢 stderr),只剩一个易误读的退出码;外加版本比对的连锁误报放大噪音。
解决
问得安全部规范:跨服务器机器场景的远程登录应使用公共账号,公共账号不走该 token 校验(安全部已实测)。把脚本中 SSH 的远程用户改为公共账号,故障消除。同时修掉脚本自身三个坑:
1 | # 1. 不再吞 stderr |
防复发
- 老服务器灰度迟早覆盖:提前把老服务器上的同类脚本也切公共账号,别等告警无声刷屏(待办)。
- 新增机器场景的自动化任务:默认按公共账号/豁免通道规划,不依赖任何登录时注入的凭证。
- 零信任类管控上线时:机器场景(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 报错」通用路径)
env -i构造最小环境,复现并确认确定性(多跑几次排抖动);- 单变量二分回加——全军覆没就放弃单变量假设;
- 成功/失败两条完整日志做 diff,差异摆脸上。
- 另注意:cron 不加载 profile,一切「登录时注入」的机制(token、代理变量、公司安全策略)对 cron 结构性失效。
环境变量传递与登录态凭证类安全方案
SendEnv/AcceptEnv:SSH 协议原生环境变量传递;客户端按变量名列表挑选转发,远端接收注入。安全方案只动配置层即可实现「凭证随会话走」。- 以「堡垒机登录」为签发条件的 JWT 凭证:本地二进制取 stdout 即 token,
readonly+ 一次性开关防篡改防重复;来源校验决定「非登录态拿不到」。 - 此类方案天然兼容人、不兼容机器;豁免通道(公共账号/服务账号)必须显式存在,机器场景接入前先问清。
PHP 脚本工程细节
exec()只收 stdout,stderr 默认丢弃;集成外部命令必须2>&1或proc_open显式接管。- 「失败返回 FALSE、调用方当字符串继续解析」是连锁误报发生器:null 比对失败会把一个基础设施故障放大成 N 条业务告警。失败要短路报错,别让 FALSE 流进业务逻辑。