SSL certificate problem 一冒出来,先别动代码。让一台机器上所有 HTTPS 请求一起失败的,占比最大的原因是系统时间——差几分钟,证书的生效期判定就全错,报错却长得像服务端配置问题。按这个顺序查:①系统时间 ②证书链本身 ③中间设备 ④运行时的证书库 ⑤极少数情况才轮到服务端。下面的命令都能直接复制运行。
证书里有两个时间点,NotBefore 和 NotAfter,客户端拿本机时间去比,落在区间外握手当场就否了,报错还常常误导成"找不到签发者"。时间会漂的原因很实在:主板纽扣电池没电、虚拟机挂起后恢复、云主机快照回滚、NTP 端口被网络挡掉、双系统来回切。
链有没有发全、叶证书什么时候到期、签发者是谁,一条 openssl s_client 全给。链里只有叶证书而没有中间证书时,命令行必报 unable to get local issuer certificate,浏览器却一切正常——这个组合本身就说明问题在链上。
企业出口做 TLS 检查、安全软件带 HTTPS 扫描,都会在中间重新签一次证书。特征就是同一段代码在家里正常、只有公司网里报错,或者链里多出不认识的签发者。
Python 读 certifi 的包内证书库或 OpenSSL 默认路径,Node 读自己内置的根证书列表,Windows 上走 schannel 的程序读系统证书库,浏览器又是另一套。四个库互不同步:根证书装进系统,Python 照样报错,因为它压根没读那个库。
只有本机诊断输出里 Verify return code 是 0,或者换几个网络环境都报同样的错,才值得怀疑服务端。这时候要做的是拿证据,不是猜。
curl: (60) SSL certificate problem: unable to get local issuer certificate——链不完整,或本地证书库里没有对应的根证书curl: (60) SSL certificate problem: certificate has expired——证书过期,或本机时间跑到未来curl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to xyuapi.top:443——不是校验失败,是握手过程中连接被切断了,出口防火墙重置、中间设备中断、SNI 被干扰都长这样60 是校验给出的结论,35 是连接异常,方向相反。
requests.exceptions.SSLError: HTTPSConnectionPool(host='xyuapi.top', port=443): Max retries exceeded with url: /v1/models (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1006)')))
真正有用的是两段:CERTIFICATE_VERIFY_FAILED 说明卡在校验环节,unable to get local issuer certificate 说明找不到签发者。_ssl.c:1006 是 CPython 的源码行号,跟你的代码无关;Max retries exceeded 是 urllib3 的重试包装,不是网络超时。后半句换成 certificate has expired 是有效期问题,换成 self signed certificate in certificate chain,基本可以断定有中间设备在签发。
Error: unable to verify the first certificate 是链里缺中间证书;UNABLE_TO_VERIFY_LEAF_SIGNATURE 是叶证书的签发者不在 Node 内置根证书列表里——Node 默认不读 Windows 证书库。走 schannel 的程序报 schannel: SEC_E_UNTRUSTED_ROOT (0x80090325),意思是根证书不在系统受信任列表里。
| 报错原文 | 真正含义 | 现在该敲的命令 |
|---|---|---|
| curl: (60) ... unable to get local issuer certificate | 链缺中间证书,或本地库没有对应根证书 | openssl s_client -connect xyuapi.top:443 -showcerts |
| curl: (60) ... certificate has expired | 证书过期,或本机时间超前 | date -u,再对一遍 openssl 里的 NotAfter |
| curl: (35) ... SSL_ERROR_SYSCALL | 握手被切断,不是校验失败 | curl -vI https://xyuapi.top/v1/models |
| CERTIFICATE_VERIFY_FAILED ... unable to get local issuer certificate | Python 用的证书库不认这条链 | python -c "import ssl;print(ssl.get_default_verify_paths())" |
| self signed certificate in certificate chain | 链里被插入自签根证书,多半是企业出口或安全软件 | 打开 certlm.msc 看受信任的根证书颁发机构 |
| Node: unable to verify the first certificate | Node 内置根证书库里没有叶证书的签发者 | 设 NODE_EXTRA_CA_CERTS 指向合并后的 PEM |
| Node: UNABLE_TO_VERIFY_LEAF_SIGNATURE | 同上,叶证书签发者不在 Node 的 CA 列表 | node -p "require('tls').rootCertificates.length" |
| schannel: SEC_E_UNTRUSTED_ROOT (0x80090325) | Windows 系统证书库没有该根证书 | certmgr.msc → 受信任的根证书颁发机构 → 导入 |
date -u
openssl s_client -connect xyuapi.top:443 -servername xyuapi.top -showcerts </dev/null 2>&1 | head -40
curl -vI https://xyuapi.top/v1/models 2>&1 | head -30
date -u 打印 UTC 时间,跟手机对一眼,差两分钟以上就先处理时间,别往下查。Windows 上对应的是:
Get-Date -AsUTC
w32tm /query /status
w32tm /resync
w32tm /resync 回 no time data was available 说明对时源没连上,手动来:设置 → 时间和语言 → 日期和时间 → 打开"自动设置时间",再点"立即同步";服务被组策略关掉就先 net start w32time。Linux 上:
timedatectl status
sudo timedatectl set-ntp true
sudo chronyc makestep
第三行给装了 chrony 的机器用,让时间立刻纠正;容器时钟跟着宿主机走,容器里时间不对就去修宿主机。
从上往下看四样:
Verify return code: 0 (ok)——本机信任链完整,去查别的地方Verify return code: 20 (unable to get local issuer certificate)——链断了,接着数段数Verify return code: 10 (certificate has expired)——过期,看 NotAfter 那行Verify return code: 18 (self signed certificate)——对端用自签证书,中间设备的典型特征再往上看 Certificate chain 下面有几段:正常两到三段,0 s: 是叶证书,1 s: 是中间证书,偶尔还有 2 s:。只有一段 0 s: 就说明服务端或你面前的中间设备没把中间证书发出来,这正是 unable to get local issuer certificate 的来源。到期时间看 NotAfter 那一行,格式是 GMT。
浏览器会按叶证书里的 AIA 字段把中间证书下载下来补上;对做过 TLS 检查的设备,系统里装了企业根证书就直接放行。curl 和 OpenSSL 只用手上拿到的那条链,不补也不猜。所以"浏览器正常、代码报错"几乎都指向证书链或证书库,不是服务端挂了。
date -u 和手机时间差超过两分钟就处理。Windows 跑 w32tm /resync,成功后用 Get-Date -AsUTC 确认;Linux 跑 sudo timedatectl set-ntp true,timedatectl status 里要看到 System clock synchronized: yes。双系统机器注意:Linux 把硬件时钟当 UTC、Windows 当本地时间,切一次差八小时,执行 timedatectl set-local-rtc 0 统一按 UTC 记。
认三个信号:手机热点下正常;链里出现不认识的签发者;报错是 self signed certificate in certificate chain。要修的是信任配置,不是关掉校验。Windows 按 Win+R 输入 certmgr.msc,进"受信任的根证书颁发机构"→ 证书 → 右键 → 所有任务 → 导入,把企业根证书 .cer 导进去。certmgr.msc 管当前用户,服务、后台进程要用 certlm.msc 装到本地计算机级。
Python 改环境变量最快:
certutil -encode corp-root.cer corp-root.pem
# PowerShell,当前会话生效
$env:REQUESTS_CA_BUNDLE="C:\certs\corp-root.pem"
# Linux / macOS
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/corp-bundle.pem
文件要是 PEM 格式,并且包含完整链:企业根证书加中间证书拼在一起。追加到 certifi 的 cacert.pem 末尾也能用,但下次升级 certifi 会覆盖掉,所以优先用环境变量。Node 用 NODE_EXTRA_CA_CERTS,值是 PEM 绝对路径,只在进程启动时读一次,改完必须重启:
set NODE_EXTRA_CA_CERTS=C:\certs\corp-root.pem
node app.js
症状是同一个域名在别的机器正常、你这台报 unable to get local issuer certificate,而环境里的 certifi 是几年前装依赖时顺带拉进来的。
python -m pip install --upgrade certifi
python -c "import certifi,os,datetime;print(certifi.where());print(datetime.datetime.fromtimestamp(os.path.getmtime(certifi.where())))"
第二条打出证书库路径和这个文件的修改时间。时间还停在两年前,说明更新没落到你正在用的解释器上——用 python -m pip 而不是裸 pip,保证两者是同一个环境。
装了抓包类工具,或者杀毒软件开着 HTTPS 扫描之后才开始报错,停掉那个模块就恢复。临时关掉 HTTPS 扫描,跑一遍 curl -vI https://xyuapi.top/v1/models,通了就是它。测完立刻把模块打开,这一步只是定位手段。长期方案:按场景二把它的根证书导进系统证书库,或者把接口域名加进软件白名单。
先拿证据:
echo | openssl s_client -connect xyuapi.top:443 -servername xyuapi.top 2>/dev/null | openssl x509 -noout -subject -issuer -dates
openssl s_client -connect xyuapi.top:443 -servername xyuapi.top -showcerts </dev/null 2>&1 | sed -n '1,40p'
第一条给主体、签发者、生效和到期时间,第二条给完整链。反馈时把这两段输出、curl -vI 的前 30 行、公网出口 IP、报错时刻一起发出去,比说一句"我这边报证书错误"有效得多。xyuapi.top 和 xyuai.cc 是两条独立线路,只有一个报错就先切另一个入口。
Chatbox、Cherry Studio 这类桌面客户端各自带运行时,证书库不一定跟着系统走。先在本机跑 curl -vI https://xyuapi.top/v1/models,返回 200 就说明问题在客户端自己的证书库。动作按顺序试:升级客户端;把根证书装到本地计算机级(certlm.msc);完全退出客户端再启动,Electron 类客户端只在启动时读一次证书库;客户端里若提供忽略证书错误的开关,只用来做一次对照确认,确认完立刻关掉。
import ssl, socket, datetime, urllib.request
from email.utils import parsedate_to_datetime
HOST = "xyuapi.top"
# ① 本机时间 vs 服务端时间:用明文 HTTP 拿 Date 头,不受证书影响
try:
with urllib.request.urlopen("http://www.baidu.com", timeout=8) as r:
server = parsedate_to_datetime(r.headers["Date"])
now = datetime.datetime.now(datetime.timezone.utc)
print("本机 UTC :", now.strftime("%Y-%m-%d %H:%M:%S"))
print("网络 UTC :", server.strftime("%Y-%m-%d %H:%M:%S"))
print("偏差(秒) :", round((now - server).total_seconds(), 1))
except Exception as e:
print("取时间失败:", e)
# ② 当前解释器认哪个证书库
p = ssl.get_default_verify_paths()
print("cafile :", p.cafile or "(未设置,走 OpenSSL 默认路径)")
print("capath :", p.capath)
try:
import certifi
print("certifi :", certifi.where())
except ImportError:
print("certifi : 未安装")
# ③ 握手一次,把签发者、SAN、到期时间打出来
ctx = ssl.create_default_context()
try:
with socket.create_connection((HOST, 443), timeout=10) as sock:
with ctx.wrap_socket(sock, server_hostname=HOST) as ss:
cert = ss.getpeercert()
print("TLS 版本 :", ss.version())
print("签发者 :", dict(x[0] for x in cert["issuer"]))
print("生效 :", cert["notBefore"])
print("到期 :", cert["notAfter"])
print("SAN :", [v for k, v in cert.get("subjectAltName", ()) if k == "DNS"][:6])
except ssl.SSLCertVerificationError as e:
print("证书校验失败:", e.verify_message, "| code =", e.verify_code)
except Exception as e:
print("握手失败:", type(e).__name__, e)
第一段拿明文 HTTP 的 Date 头当参照,超过两分钟就是时间问题;第二段说明这个解释器在读哪个证书文件;第三段把叶证书的签发者、到期时间和 SAN 打出来,过期、域名不符还是签发者不认一眼就分清。
const https = require('https');
const tls = require('tls');
const fs = require('fs');
// ca 是替换而不是追加:必须把 Node 自带的根证书一起带上,
// 否则访问其他正常站点会全部失败
const agent = new https.Agent({
ca: [...tls.rootCertificates, fs.readFileSync('C:/certs/corp-root.pem', 'utf8')],
keepAlive: true,
minVersion: 'TLSv1.2',
});
const req = https.request('https://xyuapi.top/v1/models', {
method: 'GET',
agent,
headers: { Authorization: `Bearer ${process.env.XYU_API_KEY}` },
}, (res) => {
console.log(res.statusCode, res.headers['content-type']);
res.resume();
});
req.on('error', (e) => console.error('请求失败:', e.code, e.message));
req.end();
ca 是替换语义,这是常见的坑:只传企业根证书,连别的 HTTPS 站点也会开始报错。写成数组、把 tls.rootCertificates 展开在前面,才是"多加一个信任的根"。为什么不要全局关校验:
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // 反例,别这么写
new https.Agent({ rejectUnauthorized: false }); // 反例,别这么写
rejectUnauthorized: false 关掉的正是"对端是不是真的 xyuapi.top"这个判断。之后的请求里 Authorization: Bearer ... 照样发出去,任何能改你流量的中间人都能收到这把 Key。这段配置通常写在全局 agent 上,等于整个进程的外发请求一起失去身份确认。企业根证书要处理的是"多信任谁",不是"不检查信任"。
curl -k 一跑就通,说明握手本身没问题,卡点在"校验"这一环,接着去查证书链和证书库;还是报 curl: (35) ... SSL_ERROR_SYSCALL,那就跟证书无关,是连接被切了,去查出口防火墙和中间设备。
curl -k -vI https://xyuapi.top/v1/models 2>&1 | head -20
curl -k -o /dev/null -s -w 'http=%{http_code} tls=%{time_appconnect}\n' https://xyuapi.top/v1/models
Python 里对应的是:
import requests, urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
r = requests.get("https://xyuapi.top/v1/models", verify=False, timeout=10)
print(r.status_code)
关掉校验等于放弃确认"你在跟谁说话"。TLS 干两件事:加密,以及确认对端身份。关掉的是第二件,第一件还在——流量还是密文,但你把它交给了未经确认的对象。中间人只要能把流量引过去,就能完整看到你发出去的 Key、模型名和全部提示词,再原样转发给真服务端,两边都不报错。这个开关还会传染:写下的 verify=False 会被复制到下一个文件、下一条流水线,当成一次性器械用完就扔。
确认完,把这四样在代码库里搜一遍并清掉:
verify=False(Python)rejectUnauthorized: false(Node)NODE_TLS_REJECT_UNAUTHORIZED=0(环境变量)curl -k 和 --insecure(脚本和 Dockerfile)grep -rn "verify=False|rejectUnauthorized|NODE_TLS_REJECT_UNAUTHORIZED|curl -k|--insecure" \
--include="*.py" --include="*.js" --include="*.sh" --include="Dockerfile*" .
搜出命中就改回正常校验。
| 症状 | 根因 | 动作 |
|---|---|---|
| 这台机器所有 HTTPS 一起报证书错误 | 系统时间漂出证书有效期区间 | w32tm /resync,或 sudo timedatectl set-ntp true |
| 浏览器正常,curl 报 unable to get local issuer certificate | 链里缺中间证书,命令行不补链 | 数 Certificate chain 的段数,只有一段就是没发全 |
| 公司网报错,手机热点正常 | 出口设备做了 TLS 检查 | 把企业根证书导入系统证书库 |
| Python 报 CERTIFICATE_VERIFY_FAILED,其他语言正常 | certifi 或 OpenSSL 证书库太旧 | python -m pip install --upgrade certifi |
| Node 报 unable to verify the first certificate | Node 不读 Windows 证书库 | 设 NODE_EXTRA_CA_CERTS 指向 PEM 文件 |
| 装了抓包工具或开扫描后才报错 | 中间插入了自签根证书 | 临时停用模块做对照,确认后导入根证书 |
| curl: (35) SSL_ERROR_SYSCALL | 握手被切断,不是校验失败 | curl -vI 看卡在哪一步,查出口设备 |
| 只有 xyuai.cc 报错,xyuapi.top 正常 | 该线路的证书链异常 | 先切另一条入口,附 openssl 输出反馈 |
小鱼API(xyuai.cc)是 AI API 接入平台,把主流大模型聚合成一个 OpenAI 兼容接口,改一行 base URL 就能接上。
https://xyuapi.top/v1;备用入口:https://xyuai.cc/v1https://xyuapi.top/v1betaPOST /v1/chat/completions,模型列表 GET /v1/models配客户端时的证书相关坑:base URL 要带 /v1,填成裸域名会 404,某些客户端把 404 包装成"连接失败",让人误以为是证书问题。先跑 curl -vI https://xyuapi.top/v1/models 确认命令行能通,再配客户端。
| 模型 | 单价 |
|---|---|
| gemini-2.5-pro | 0.031 元/次 |
| deepseek-v3.2-thinking | 0.049 元/次 |
| deepseek-r1-thinking | 0.049 元/次 |
| grok-4.1 | 0.05 元/次 |
| gemini-3-pro-preview | 0.05 元/次 |
| claude-sonnet-4-5-thinking | 0.09 元/次 |
| kimi-k2.5 / kimi-k2.6 | 0.09 元/次 |
| claude-opus-4-5-thinking | 0.12 元/次 |
| gpt-5.4-pro-thinking | 0.15 元/次 |
| claude-sonnet-4-6-thinking | 0.2 元/次 |
| claude-opus-4-6-thinking | 0.25 元/次 |
| NanoBanana-Pro(生图) | 0.27 元/次 |
| gpt-5.3-pro | 0.3 元/次 |
按次计费的好处是排查成本可预期:一次请求多少钱是固定的,上下文塞多长都一样。
证书问题解决后,验证整条链路省事的办法就是打一次模型列表:
curl -s -H "Authorization: Bearer $XYU_API_KEY" https://xyuapi.top/v1/models | head -c 300
返回的 JSON 里有带 id 的模型名,说明从 TLS 握手到鉴权这一路都通了。换成 https://xyuai.cc/v1/models 再跑一次,两条入口都通才算这台机器真的没问题。