从裸调curl到工程级封装:SSL证书检测API的演进实践
为什么需要从 curl 走向工程封装
排查一个域名的 HTTPS 证书状态,最快的方式是敲一条 curl。curl -sS "https://v1.apizero.cn/api/ssl?domain=example.com"能在几秒内返回证书的颁发者、有效期、指纹等信息。但将这条命令从终端搬进生产系统时,会立刻遇到几个实际问题:
- 返回结果里
is_ssl=false和code=502都表示"拿不到证书",但语义完全不同,需要区分处理。 - 上游对 QPS 有约束,突发的批量检测会触发限流。
- 证书信息 6 小时缓存即可,无需每次请求都打上游。
- API Key 直接写在命令行里,存在泄露风险。
curl 是调试工具,不是运维组件。本文以 SSL 证书检测 API 为例,梳理从裸调 curl 到工程封装的完整路径。
接口能力边界:它能做什么,不能做什么
调用任何接口之前,先明确它的职责边界。根据接口事实卡,SSL 证书检测 API 的能力如下。
可交付的能力
| 输出项 | 说明 |
|---|---|
| 证书颁发者 | issuer,如 "Certum DV TLS G2 R39 CA" |
| 颁发机构 | issuing_agency,即 CA 所属组织 |
| 签名算法 | signature_algorithm,如 RSA-SHA256 |
| 覆盖域名 | domains数组,含通配符和多域名 |
| 有效期 | start_date与expire_date时间戳 |
| 过期状态 | is_expired布尔值 |
| 距过期天数 | expire_days整数 |
| 指纹 | fingerprint,SHA-1 格式 |
| 远端 IP | remote_address,含端口号 |
请求前的智能预处理
接口内部做了域名标准化,自动剥离http(s)://前缀、路径、查询串、端口和www.子域。这意味着开发者传https://www.example.com:443/path?q=1也能被正确归约为example.com再探测。但需要注意,剥离逻辑只对合法域名生效。
严格域名校验
仅当域名格式合法(最长 253 字符,且标签符合 RFC 1123 规范)时,请求才会打到上游探测服务。不合法的输入会在入口被拦截,不会消耗上游配额。
三态响应模型
这是本接口最需要理解的设计:
- 有 SSL 证书:
is_ssl = true,data字段携带完整证书信息。 - 无 SSL 或连接失败:
is_ssl = false,其余证书字段为null。此时域名本身可能无法访问,也可能只支持 HTTP。 - 上游异常:返回 HTTP 502,通常表示探测服务自身出现了问题。
区分后两种状态非常重要:前者是业务结论("该域名没开 SSL"),后者是基础设施故障("探测服务不可用"),二者在告警策略上应当截然不同。
请求参数与鉴权方式
Query 参数
| 参数 | 类型 | 是否必填 | 说明 |
|---|---|---|---|
domain | string | 是 | 目标域名,可带协议、路径、端口、www.前缀,会自动剥离 |
唯一的必填参数是domain,传错或缺失会直接导致校验失败。
Header 鉴权
| 参数 | 类型 | 是否必填 | 说明 |
|---|---|---|---|
Authorization | string | 否 | 格式为Bearer sk_live_xxx;匿名调用时省略 |
事实卡中给出的 curl 示例使用的是X-API-Key请求头,而后面的响应示例与鉴权说明描述的是Authorization: Bearer方式。实际接入时,需要以官方文档页(https://apizero.cn/aidocs/ssl)为准,确认当前生产环境推荐使用的鉴权头格式。
从 curl 开始的首次调用
基础请求模板
以下 curl 命令是事实卡中提供的原始示例,将$APIZERO_API_KEY替换为实际密钥后即可执行:
curl -sS \ -X GET \ -H "X-API-Key: $APIZERO_API_KEY" \ "https://v1.apizero.cn/api/ssl?domain=apizero.cn"若使用匿名调用,可以去掉 Header 参数,直接访问:
curl -sS -X GET "https://v1.apizero.cn/api/ssl?domain=apizero.cn"带输入预处理的调用
实际使用中,用户输入往往带有前缀或路径,可以先把原始输入传给接口,利用其智能剥离能力:
curl -sS \ "https://v1.apizero.cn/api/ssl?domain=https://www.apizero.cn/some/path?utm=1"接口会自动剥离https://、www.、路径与查询串,最终探测apizero.cn的证书。
返回字段逐项解读
以事实卡中的成功响应为例,逐字段分析语义。
{ "code": 0, "data": { "common_name": "*.apizero.cn", "domain": "apizero.cn", "domains": ["*.apizero.cn", "apizero.cn"], "expire_date": "2026-11-07 17:04:59", "expire_days": 184, "fingerprint": "f4633adfd1cb59185ba094dc3edeef5a8ea26889", "is_expired": false, "is_ssl": true, "issuer": "Certum DV TLS G2 R39 CA", "issuing_agency": "Asseco Data Systems S.A.", "life_span_days": 198, "remote_address": "119.36.225.184:443", "signature_algorithm": "RSA-SHA256", "start_date": "2026-04-22 17:05:00" }, "msg": "成功", "request_id": "abc123def456" }核心字段说明
| 字段 | 类型 | 业务含义 |
|---|---|---|
code | int | 业务状态码,0表示成功 |
request_id | string | 请求追踪 ID,排查问题时需要记录 |
common_name | string | 证书的主域名(CN 字段) |
domains | string[] | SAN 扩展中的完整域名列表,比 CN 更全面 |
expire_days | int | 从探测时刻到证书过期的剩余天数 |
life_span_days | int | 证书总有效天数 |
is_expired | bool | 是否已过期 |
is_ssl | bool | 目标域名是否存在有效 SSL 证书 |
remote_address | string | 探测时解析到的远端 IP 与端口 |
字段标准化规则
接口在返回前做了两层标准化处理:
- 类型统一:自动将数字字符串转为 int,将
is_expire统一重命名为is_expired。 - null 透传:上游返回 null 时,接口保持 null,不会填充 0 或空字符串。
这意味着判断字段是否存在时,不能只判断真假值,还要判断是否为null。
无证书时的响应形态
当目标域名没有 SSL 证书时:
{ "code": 0, "data": { "domain": "example.com", "is_ssl": false, "common_name": null, "domains": null, "expire_date": null, "is_expired": null, "fingerprint": null, "remote_address": null } }注意code仍为0,业务状态是成功的,但data中证书相关字段全部为null。
常见错误与排查路径
在 curl 阶段最容易踩到这几类问题。
域名参数非法
传入http://但后面没有合法域名、域名长度超过 253 字符、或标签中出现非法字符,都会触发严格域名校验失败。此时应先检查 URL 编码:
curl -sS "https://v1.apizero.cn/api/ssl?domain=$(python3 -c 'import urllib.parse; print(urllib.parse.quote("https://例.cn"))')"鉴权头格式不匹配
事实卡的 curl 示例使用X-API-Key,而参数表格描述的是Authorization: Bearer sk_live_xxx。如果接口实际启用的是后者,使用错误的 Header 名会返回鉴权失败。解决方法是查阅官方文档确认当前生效的鉴权方式,并写一个简单的配置层来存放 Header 名称与格式。
502 与 is_ssl=false 的混淆
很多开发者把这两个情况混为一谈,统一当成"没有证书"处理。事实上:
is_ssl=false是确定性结论,可以缓存、可以展示给用户。- HTTP 502 是探测服务异常,需要告警并稍后重试。
建议在封装层将二者映射为不同的内部状态码。
工程化封装实践
当调用量超过个位数、且要对结果负责时,封装就不是把 curl 换成 requests 那么简单。以下五个维度是必须考虑的。
1. 缓存策略
根据事实卡给出的缓存建议,做两级缓存:
- 有证书的域名:缓存 6 小时。证书短期内不会变更,频繁探测徒增上游压力。
- 无证书的域名:缓存 30 分钟。避免错误域名或未开通 SSL 的域名反复触发上游探测。
用伪代码表示:
import redis CACHE_TTL_WITH_SSL = 6 * 60 * 60 CACHE_TTL_NO_SSL = 30 * 60 def get_ssl_info(domain: str): key = f"ssl:cache:{domain}" cached = redis_client.get(key) if cached: return cached data = fetch_from_api(domain) ttl = CACHE_TTL_WITH_SSL if data.get("data", {}).get("is_ssl") else CACHE_TTL_NO_SSL redis_client.setex(key, ttl, json.dumps(data)) return data2. 重试与退避
上游异常(502)和网络超时应进行有限重试。推荐使用指数退避,且设置最大重试次数为 3。
import time def fetch_with_retry(domain: str, max_retries=3): for attempt in range(max_retries): response = requests.get("https://v1.apizero.cn/api/ssl", params={"domain": domain}, timeout=10) if response.status_code == 502 and attempt < max_retries - 1: time.sleep(2 ** attempt) continue return response raise RuntimeError(f"upstream unavailable for {domain}")3. QPS 配额控制
接口配额为 QPS 5,意味着粗放地起线程池批量扫描很快会触发限流。封装一个信号量作为并发闸门:
import asyncio import aiohttp semaphore = asyncio.Semaphore(4) async def fetch_with_semaphore(domain: str): async with semaphore: async with aiohttp.ClientSession() as session: async with session.get( "https://v1.apizero.cn/api/ssl", params={"domain": domain} ) as resp: return await resp.json()4. 鉴权管理
不要将 API Key 硬编码在代码或命令行中。建议:
- 读取环境变量或密钥管理服务。
- 在日志中脱敏,禁止打印 Authorization 头。
- 定期轮换,配合服务的
request_id做调用审计。
5. 结果持久化与变更感知
证书到期预警的核心逻辑是"对比上次与本次"。可以设计一个ssl_checks表,保留每次探测的expire_days与fingerprint。当fingerprint发生变化时,说明证书被重新颁发;当expire_days低于阈值时,触发告警。
CREATE TABLE ssl_checks ( id BIGINT PRIMARY KEY AUTO_INCREMENT, domain VARCHAR(253) NOT NULL, fingerprint CHAR(40), expire_days INT, is_expired BOOLEAN, checked_at DATETIME NOT NULL, INDEX idx_domain_time (domain, checked_at) );封装层的统一返回设计
不建议把上游的data原样透传给业务方。建议组装成一个规范化结构:
def build_normalized_response(raw_json: dict): data = raw_json.get("data") or {} is_ssl = data.get("is_ssl", False) return { "domain": data.get("domain"), "has_ssl": is_ssl, "expires_at": data.get("expire_date"), "expires_in_days": data.get("expire_days"), "certificate_fingerprint": data.get("fingerprint"), "issuer": data.get("issuer"), "remote_endpoint": data.get("remote_address"), "error_type": None if is_ssl or raw_json.get("code") else "NO_SSL", "request_id": raw_json.get("request_id") }这样业务侧只需要关心has_ssl、expires_in_days和error_type三个字段,不需要理解 SSL 证书领域的全部细节。
踩坑清单
- 不要把
502当作"无证书":前者重试,后者走缓存。 - 区分
null与false:is_ssl=false时其余字段为null,用is not None判断会误伤。 - 参数要 URL 编码:中文域名、带
#的 URL 直接拼接在 URL 里会被截断。 - 匿名调用有次数限制,超限后应切到带鉴权的方式,调用前先读文档确认 Header 格式。
- 定时任务不要卡在整点:缓存集中过期会让上游在某一秒压力陡增,适当引入随机抖动。
参考文档
- 文档页:https://apizero.cn/aidocs/ssl
- 原始文档(含完整说明与更新记录):https://apizero.cn/aidocs/ssl/raw.md
