
证书过期不是“慢慢失效”,而是到点直接失效:浏览器整页拦截、App 内 H5 白屏、小程序请求全部失败、第三方回调因 TLS 校验失败被丢弃。更隐蔽的是配置问题——证书链没发全、还开着 TLS 1.0、HSTS 没配、证书域名与访问域名不匹配,这些在桌面 Chrome 上往往"看起来正常",却在 Android 旧版本、Java 8 客户端、IoT 设备上直接握手失败。
本文给出一套可直接落地的巡检方法:用 openssl + curl 写一个可复现的探测脚本,覆盖有效期、协议版本、证书链、主机名校验、HTTP 跳转、HSTS、OCSP Stapling 七个维度,再说明如何把它挂到定时任务或阿里云函数计算上做周期性巡检,并与阿里云数字证书管理服务的证书生命周期能力配合使用。
一、场景与目标
典型触发场景:
- 团队用免费证书或商业证书,但续期靠人肉记日历,证书数量超过 10 张后必然漏;
- 站点迁到 CDN、负载均衡 SLB、应用型负载均衡 ALB、Web 应用防火墙 WAF 之后,证书要在边缘节点和源站分别维护,容易出现“边缘换了、源站没换”;
- 等保、密评或客户安全问卷要求提供“TLS 配置合规”的证明材料,但没人能说清当前到底开了哪些协议套件。
本文的目标不是做漏洞扫描,而是做配置基线巡检:对一组域名周期性输出结构化结果,证书剩余天数低于阈值、或出现禁止的协议版本时能触发告警。
二、前置条件与环境
| 项 | 要求 |
|---|---|
| 执行环境 | Linux(GNU coreutils 的 date -d 依赖),或任意装有 openutils 的容器 |
| OpenSSL | 建议 1.1.1 及以上(支持 TLS 1.3 与 -verify_hostname);openssl version 确认 |
| curl | 7.29 及以上,需要 -w '%{http_code}' 与 %{redirect_url} |
| 网络 | 执行机能直连目标 443/tcp 与 80/tcp;不要从被 WAF 拦截的出口发起高频探测 |
| 授权 | 只巡检自有或已获书面授权的域名;脚本仅做 TLS 握手与 HTTP HEAD 请求,不含任何攻击载荷 |
注意一个前提:审计对象是域名,不是 IP。脚本必须带 -servername(SNI),否则在共享 IP 的 CDN 或 SLB 上会拿到默认证书,结论完全错误。
三、方案选择:三条路线的取舍
路线 A:本地脚本 + cron/systemd timer。 成本最低、完全可控、结果可入自有监控;缺点是执行机单点,跨地域探测能力弱。
路线 B:阿里云函数计算(Function Compute)+ 定时触发器。 无需维护执行机,天然适合多域名批量巡检,结果可写入日志服务 SLS 并配置告警。适合域名数量在几十到几百、需要固定周期执行的场景。需注意:函数默认的公网访问能力与是否配置 VPC 有关,若目标在 VPC 内网,需要提前规划 NAT 网关等出口,具体以官方文档为准。
路线 C:托管能力组合。 数字证书管理服务负责证书签发、托管与到期提醒,云监控的站点监控负责可用性与响应时间探测。这类能力开箱即用,但探针维度和告警粒度受产品边界约束,证书链完整性、协议版本矩阵这类细节仍建议用脚本自查。是否支持特定云产品的证书部署、提醒提前天数与渠道,请以控制台和官方文档为准。
实践中的常见组合是:C 管证书生命周期,A 或 B 管配置基线,两者结果交叉比对。
四、实施步骤
- 梳理资产:把对外提供 HTTPS 的域名列全,包括主域名、API 子域、静态资源域、回调域。特别注意 CDN 加速域名和 WAF 防护域名,它们与源站证书是两套。
- 确定基线:建议基线为——TLS 1.2 / 1.3 开启,TLS 1.0 / 1.1 关闭;证书链完整;主机名校验通过;HTTP 301/308 跳转到 HTTPS;HSTS 至少
max-age=31536000;剩余天数阈值 30 天(免费 90 天证书可设 21 天)。 - 单域名验证:先用脚本跑通一个域名,人工核对结论与浏览器开发者工具的安全面板是否一致。
- 批量巡检:用
xargs -P并发,控制并发度在 4~8,避免触发 WAF 频控。 - 接告警:脚本用退出码表达状态(0 正常 / 1 告警 / 2 探测失败),由外层调度器判断并推送。
- 闭环:把“证书续期”和“配置变更”纳入变更流程,续期后在边缘节点与源站两侧都复跑一次。
五、关键配置与代码
以下脚本可直接保存为 ssl_audit.sh,chmod +x 后执行 ./ssl_audit.sh www.example.com。
#!/usr/bin/env bash
# ssl_audit.sh —— 单域名 HTTPS 配置巡检
# 用法: ./ssl_audit.sh <host> [port]
# 退出码: 0 正常 / 1 存在告警项 / 2 探测失败(握手不成功)
set -uo pipefail
HOST="${1:?usage: ssl_audit.sh <host> [port]}"
PORT="${2:-443}"
WARN_DAYS="${WARN_DAYS:-30}"
TIMEOUT=10
SCLIENT=(openssl s_client -connect "${HOST}:${PORT}" -servername "${HOST}")
fail=0
emit() {
printf '%-24s %s\n' "$1" "$2"; }
# ---------- 1. 握手 + 证书有效期 ----------
cert=$(echo | "${SCLIENT[@]}" 2>/dev/null | openssl x509 -noout -subject -issuer -dates 2>/dev/null)
if [ -z "$cert" ]; then
emit "tls_handshake" "FAIL"
exit 2
fi
emit "tls_handshake" "OK"
emit "subject" "$(echo "$cert" | awk -F'=' '/^subject/{print $2}')"
emit "issuer" "$(echo "$cert" | awk -F'=' '/^issuer/{print $2}')"
end_date=$(echo "$cert" | awk -F'=' '/^notAfter/{print $2}')
end_ts=$(date -u -d "$end_date" +%s)
days=$(( (end_ts - $(date -u +%s)) / 86400 ))
emit "days_remaining" "$days"
[ "$days" -lt "$WARN_DAYS" ] && fail=1
# ---------- 2. 协议版本 ----------
probe_proto() {
out=$(echo | "${SCLIENT[@]}" "-$1" 2>/dev/null)
if [ -z "$out" ] || echo "$out" | grep -q "Cipher is (NONE)"; then echo "OFF"; else echo "ON"; fi
}
for v in tls1 tls1_1 tls1_2 tls1_3; do
s=$(probe_proto "$v")
emit "proto_$v" "$s"
# 基线:1.0/1.1 必须 OFF,1.2/1.3 至少一个 ON
case "$v" in
tls1|tls1_1) [ "$s" = "ON" ] && fail=1 ;;
esac
done
# ---------- 3. 证书链完整性 ----------
chain_cnt=$(echo | "${SCLIENT[@]}" -showcerts 2>/dev/null | grep -c "BEGIN CERTIFICATE")
emit "chain_certs" "$chain_cnt" # 通常 >=2(叶子 + 中间 CA)
[ "$chain_cnt" -lt 2 ] && fail=1
# ---------- 4. 链校验 + 主机名匹配 ----------
verify=$(echo | "${SCLIENT[@]}" -verify_return_error -verify_hostname "$HOST" 2>&1 \
| grep -i "Verify return code" | head -n1)
emit "verify" "${verify:-UNKNOWN}"
echo "$verify" | grep -q "0 (ok)" || fail=1
# ---------- 5. HTTP -> HTTPS 跳转 ----------
code=$(curl -sS -o /dev/null -m "$TIMEOUT" -w '%{http_code}' "http://${HOST}/" 2>/dev/null || echo 000)
emit "http_code" "$code"
case "$code" in
301|308) ;;
*) fail=1 ;;
esac
# ---------- 6. HSTS ----------
hsts=$(curl -sSI -m "$TIMEOUT" "https://${HOST}/" 2>/dev/null \
| awk -F': ' 'tolower($1)=="strict-transport-security"{print $2}' | tr -d '\r')
emit "hsts" "${hsts:-MISSING}"
[ -z "$hsts" ] && fail=1
# ---------- 7. OCSP Stapling ----------
ocsp=$(echo | "${SCLIENT[@]}" -status 2>/dev/null | grep -A1 "OCSP Response Status" | tail -n1 | tr -d ' ')
emit "ocsp_stapling" "${ocsp:-NONE}"
exit "$fail"
批量执行与结果留存:
mkdir -p out
cat > domains.txt <<'EOF'
www.example.com
api.example.com
static.example.com
EOF
# GNU xargs,并发 4
xargs -a domains.txt -P 4 -I{
} sh -c \
'./ssl_audit.sh "{}" > "out/{}.log" 2>&1; echo "{} => exit=$?"'
若要接入函数计算,把上面的逻辑改写为 Python(ssl 模块取 getpeercert() 的 notAfter,socket 指定 ssl.PROTOCOL_TLS_CLIENT 并按版本构造上下文),配置定时触发器(Cron 表达式),将结果以 JSON 行写入日志服务 SLS,再对 days_remaining < 30 或 proto_tls1 == ON 配置日志告警。注意函数执行超时要覆盖全部域名的串行耗时,必要时在函数内并发。
六、验证结果
单域名输出形如下面(仅为字段格式示意,不代表任何真实站点数据):
tls_handshake OK
subject CN=www.example.com
issuer C=US, O=Example CA, CN=Example RSA DV
days_remaining 76
proto_tls1 OFF
proto_tls1_1 OFF
proto_tls1_2 ON
proto_tls1_3 ON
chain_certs 2
verify Verify return code: 0 (ok)
http_code 301
hsts max-age=31536000
ocsp_stapling successful
人工核对方法:
- 证书指纹与到期时间,和浏览器开发者工具 → 安全 → 查看证书 中的结果比对;
- 协议版本,可用
openssl s_client -connect host:443 -servername host -tls1_1手工复现,确认返回的是握手失败而非空输出; - 证书链,把
-showcerts的输出另存为.pem,用openssl verify -untrusted chain.pem leaf.pem复核; - 若脚本结论与浏览器不一致,优先怀疑 SNI 缺失、中间有代理做了 TLS 卸载、或本地系统时间偏差。
七、成本、性能与安全注意事项
性能。 单域名全量探测约需 6~8 次 TLS 握手加 2 次 HTTP 请求,量级在百毫秒到秒级,取决于网络 RTT。100 个域名串行执行可能达到分钟级;并发 4~8 通常可压到十几秒。但并发不要盲目拉高:一方面会触发 WAF 的 CC 防护,另一方面部分 SLB 或 CDN 实例对握手速率有保护策略。
成本。 本地 cron 方案只消耗执行机资源。函数计算按调用次数与资源使用量计费,是否落在免费额度内取决于你的调用量与规格,请以官方计费文档为准。日志服务 SLS 按存储量与读写流量计费,注意给结果设置合理的保留周期。
安全。
- 脚本结果含证书主体与颁发者信息,属于资产指纹,日志不要无鉴权公开;
- 不要在脚本里硬编码 AK/SK,函数计算场景使用角色授权而非在代码中放置长期凭证;
- 巡检频率建议 1 次/小时到 1 次/天,不要做成秒级压测;
- 关闭 TLS 1.0/1.1 前先确认没有遗留客户端,可通过 SLS 或 Nginx
access_log中的$ssl_protocol变量统计真实流量分布,再灰度关闭; - 开启 HSTS 前务必确认全站(含所有子域,若使用
includeSubDomains)都已 HTTPS 可用,否则回滚代价很高,必要时先用较短的max-age观察。
八、常见问题
Q1:脚本报握手失败,但浏览器能打开?
先加 -servername;再确认执行机出网是否被安全组或 NAT 策略拦截;最后排查是否被WAF 按 User-Agent 拦截。
Q2:chain_certs 是 1,但浏览器显示正常?
桌面浏览器会做 AIA 补链,Android 与 Java 客户端通常不会。链不全的表现是“部分客户端报 unable to get local issuer certificate”,必须在服务端把中间证书一并下发。
Q3:verify 报 unable to verify the first certificate 或主机名不匹配。
前者的常见原因是链不全或用了私有 CA;后者检查证书 SAN 是否覆盖实际访问域名,泛域名证书只覆盖一级子域。
Q4:CDN 后面怎么审计?
以加速域名为审计对象。CDN 回源时的证书是另一条链路,需要单独探测源站或回源域名,不要用边缘节点的结果推断源站配置。
Q5:date -d 在 macOS 上报错。
GNU 与 BSD date 参数不同,建议在 Linux 容器内执行,或用 Python 版本替代。
Q6:为什么 proto_tls1 显示 OFF 但退出码仍是 1?
检查其他告警项——退出码是聚合结果,逐行看具体哪一项 FAIL。
Q7:数字证书管理服务里已续期,脚本还显示旧证书?
续期不等于部署。证书需要重新上传或部署到 SLB、ALB、CDN、WAF 等具体实例上,各产品的支持范围与生效时间不同,请以控制台实际状态为准,部署后复跑一次脚本确认。
九、总结
SSL 证书检测的价值不在“查一次”,而在“变成常态化的基线”。把有效期、协议版本、证书链、主机名、跳转与 HSTS 这几项固化成脚本的退出码,再挂上定时调度,证书过期就从“事故”降级为“工单”。托管服务负责证书本身的签发与提醒,自建脚本负责配置细节的兜底,两者不是替代关系。
建议读者先从 3~5 个核心域名开始跑通脚本,确认输出与浏览器一致后,再扩到全量资产并接入告警。最终请结合自己的账号权限、地域和业务环境完成验证:不同地域的产品能力、证书部署支持范围、计费方式与告警渠道都可能存在差异,以阿里云官方文档和控制台实际展示为准。
另外,我也建议定期给网站做一次诊断检查,及时发现问题,尽早解决处理,这里推荐一款我经常使用的在线诊断工具(实用工具吧-网站综合诊断),浏览器打开即可使用,免费免注册,方便快捷。