用 openssl s_client 连 HTTPS,打印了证书和 Cipher,退出码也为 0,可浏览器仍报不信任。诊断工具愿意继续握手,不等于证书通过验收。本文拆开身份验证与 Shell 退出码,给出可执行检查方法。
一、握手能继续,不代表证书可信
s_client 的定位是 TLS 诊断工具。OpenSSL 文档明确说明,它默认会在证书验证出错后继续握手,方便观察更多问题;加入 -verify_return_error 才会在验证错误时中止。因此,看到 CONNECTED、协商出的密码套件,甚至进程退出码为 0,都不能单独证明服务端身份可信。它像愿意把检查报告打印完的医生,不是盖了"全部合格"的章。
本文检查直接 TLS 的 HTTPS 服务,区分网络连通、协议协商与身份验证,不覆盖 STARTTLS 或业务接口验收。

二、先固定终点、SNI 与验证名
以下使用文档示例地址,执行前替换成自己有权检查的目标。-connect 决定连接终点,-servername 发送 SNI,帮助服务端选择证书;-verify_hostname 才声明要验证的 DNS 身份。不能因为写了 SNI,就以为主机名检查也自动配好了。
bash
openssl version -a
openssl s_client -connect 192.0.2.10:443 \
-servername service.example.com \
-verify_hostname service.example.com \
-verify_return_error -CAfile root.pem \
-no-CApath -no-CAfile </dev/null
这里的 root.pem 必须来自可信的配置或交付渠道,不是从当前报错连接里随手抓一张证书就设为信任锚。显式 CA 文件与关闭默认信任文件、目录搭配,是为了减少本次诊断的环境变量;它不会修改系统信任库。连接 IP 而验证域名时仍用域名检查;真正以 IP 作为服务身份则需使用 -verify_ip,两者不能混用。
三、让验证失败真正影响脚本结果
建议先存完整输出再分类。下面用 Bash 与 GNU timeout 设置时限;超时与证书错误分别记录。
bash
if timeout 10 openssl s_client \
-connect 192.0.2.10:443 \
-servername service.example.com \
-verify_hostname service.example.com \
-verify_return_error -CAfile root.pem \
-no-CApath -no-CAfile </dev/null >tls.log 2>&1
then
rc=0
else
rc=$?
fi
printf 'TLS检查退出码=%s\n' "$rc"
把命令放进 if 条件,还能避免启用 set -e 时在读取错误码之前就提前退出。</dev/null 让这次检查不等待人工输入;不要又加 -ign_eof 使它继续等待。此处只探测 TLS,不发送 HTTP 请求,也不声称网站内容正常。
非零只能说明本次检查失败,不能一概翻译成"证书过期":连接拒绝、超时、参数错误、缺失 CA 文件都可能失败。监控应保留原始日志和阶段,再决定告警类别。正常输出也应核对实际协商信息,不能只凭一行文案放行。
四、为什么接上 tee 后又变成成功
在 Bash 的默认管道语义下,管道状态通常来自最后一个命令。s_client 失败了,后面的 tee 却可能成功写完日志;如果此时只读 $?,读到的是管道结果,不是前面 TLS 检查的状态。
bash
set -o pipefail
if timeout 10 openssl s_client \
-connect 192.0.2.10:443 \
-servername service.example.com \
-verify_hostname service.example.com \
-verify_return_error -CAfile root.pem \
-no-CApath -no-CAfile </dev/null 2>&1 | tee tls.log
then
printf '管道成功,继续核对握手信息\n'
else
rc=$?
printf '检查管道失败,退出码=%s\n' "$rc"
fi
pipefail 防止上游失败被覆盖,但 tee 写盘失败也会导致管道失败,不能直接当成证书错误。逐项追踪可立即保存 Bash 的 PIPESTATUS;更简单的做法是用上一节无管道示例。
五、用正反例确认检查真的有效
本次在回环地址启动独立 OpenSSL s_server,使用临时根、临时服务端证书和另一个不相关根,固定 TLS 1.2 做对照;实际 CLI 为 OpenSSL 1.1.1k FIPS。没有改生产 Nginx、系统时间或信任库,实验结束关闭监听并删除测试私钥。
| 实验条件 | 本机结果 | 含义 |
|---|---|---|
| 错误根,诊断模式 | 报告验证错误,退出 0 | 进程成功不等于信任成功 |
| 错误根,加严格选项 | 验证失败,退出 1 | 负例可阻断握手 |
| 正确根与验证名 | 验证通过,退出 0 | 本次 TLS 正例成立 |
| 只更换 SNI,不指定验证名 | 仍可能退出 0 | SNI 不是身份检查开关 |
| 正确根、错误验证名 | hostname mismatch,退出 1 | 名称负例可阻断 |
这组退出值是上述版本与夹具的实测,不是所有 OpenSSL 版本、协议和异常场景的固定对照表。临时服务端只提供一张证书,SNI 负例用于说明"发送名字不等于验证名字",不能据此推断多虚拟主机的选证规则。没有进行浏览器或生产负载测试。
六、抓到证书也要分清证据类型
-showcerts 展示服务端发送的证书列表,并不是已经验证通过的链。openssl x509 能读出 SAN、有效期或指纹,也只是解析材料。文件检查与网络检查要互相补充,不能互相顶替。
bash
openssl verify -CAfile root.pem \
-untrusted intermediate.pem -purpose sslserver \
-verify_hostname service.example.com leaf.pem
上面验证离线材料:根作为信任来源,中间证书用于构链,叶子作为目标。即使返回 OK,也不证明线上终点正在发送同一份材料。反过来,网络日志显示错误时,先保留错误 depth、验证名与终点,再回查证书链;不要为让检查变绿而删除名称验证、放宽信任或关闭证书校验。
七、把 TLS 验收接到业务验收之前
严格 TLS 检查通过后,仍要用业务客户端访问授权的健康检查地址,核对 HTTP 状态、重定向、响应内容和实际使用的信任配置。一个进程用显式 root.pem 成功,不能代表容器、Java 或浏览器的独立信任库都成功。
记录终点、SNI、验证名、版本、时间与退出码。报告公开前清理真实域名及会话数据,不输出私钥或 TLS 会话密钥。
八、发布或巡检前的检查清单
- 连接终点、SNI 与验证身份已分别指定,没有用 SNI 代替名称检查。
- 启用 verify_return_error,信任来源明确,错误根与错误名称两个负例都能报警。
- 没有丢弃 stderr;退出码在下一条命令之前保存,管道失败不会被覆盖。
- 超时、网络失败和证书失败分开处理;没有只搜索一行成功字符串。
- 离线验证、在线握手和业务请求分别留证,没有把一次成功扩大成全客户端通过。
参考:OpenSSL s_client 官方文档、OpenSSL verify 官方文档、Bash 管道退出状态。参数行为应以实际安装版本及本地负例回归为准。