一次很隐蔽的 Cloudflare API 故障排查:不是 WAF,不是回源,而是 Minimum TLS Version

最近排查了一个让我印象非常深的故障。

现象表面上看,是一个很普通的 API 接入问题:sub2api 在不开 Cloudflare CDN 时,一切正常;一旦给域名加上 Cloudflare 代理,来自 codex 的请求就会失败。失败时,客户端报错类似:

1
stream disconnected before completion: error sending request for url (https://api-a.example.com/responses)

如果只看这句报错,第一反应通常会是:

  • 是不是 sub2api 对流式请求支持有问题?
  • 是不是 Cloudflare 把请求拦了?
  • 是不是回源失败了?
  • 是不是接口路径被改写了?
  • 是不是 CDN 不适合拿来代理这类 API?

但这次真正的根因,和这些猜测都不完全一样。

最后定位下来,问题既不在应用本身,也不在 WAF 或回源,而是在一个很容易被忽略的 Cloudflare 配置项上:

Minimum TLS Version 被设成了 TLS 1.3

这个问题最“阴”的地方就在于,它表现得像应用层故障,但实际上请求根本还没有进入 HTTP 层,死在了客户端和 Cloudflare 之间的 TLS 握手阶段。

这篇文章想把整个排查过程、思路转折、最终证据链,以及从中得到的经验完整记录下来。以后自己再遇到类似问题,或者别人遇到“开 CDN 后源站完全没日志”的诡异现象时,至少能少走一些弯路。

一、背景:sub2api 接入 codex,没开 CDN 时一切正常

我的服务基于 sub2api,平时作为一层接口转换和聚合入口使用。不开 Cloudflare 代理时,来自 codex 的请求可以正常打到接口,整体行为稳定,没有明显异常。

后来为了统一流量入口、顺便利用 Cloudflare 的代理能力和安全能力,我把 API 域名接到了 Cloudflare 上。原本以为这只是把链路从:

codex -> sub2api

变成:

codex -> Cloudflare -> sub2api

在这个过程中,应用逻辑本身并没有变化,所以按经验来说,最多是补一些 WAF 跳过规则、缓存绕过规则,就应该可以正常跑起来。

结果实际一开代理,问题就来了。

二、事故现象:开 CDN 前正常,开 CDN 后 codex 失败,源站无日志

故障现象非常集中,也非常具有误导性。

问题域名这里匿名化为 api-a.example.com。 另外我还有一个行为正常的对照域名,匿名化为 api-b.example.com

表现如下:

  • api-a.example.com 开启 Cloudflare 代理后,codex 请求失败
  • 失败报错为:
1
stream disconnected before completion: error sending request for url (https://api-a.example.com/responses)
  • sub2api 日志中没有对应请求记录
  • 源站应用也几乎看不到这次调用痕迹
  • 我已经针对 /responses/api/ 等路径配置了 Cloudflare Skip 规则
  • 但问题依然存在
  • 另一个同样在 Cloudflare 后面的域名 api-b.example.com 却能正常工作

这几条现象叠在一起时,真的会让人陷入一种很典型的误区:

“是不是 Cloudflare 拦了,但我没找到规则?” “是不是源站没收到,所以没有日志?” “是不是 codex 的流式请求和 CDN 不兼容?”

这些猜测都很自然,也都很像真正的原因。

但问题恰恰在于,这次故障看上去“像”很多常见问题,实际上却不是其中任何一个最直观的版本。

三、最开始最容易怀疑的方向

回头看,这次排障一开始最容易被带偏到这些方向:

1. WAF / Bot Fight / Browser Integrity Check

因为只要开了 Cloudflare 代理,大家都会本能地先怀疑:

  • WAF Managed Rules
  • Super Bot Fight Mode
  • Browser Integrity Check
  • 自定义防火墙规则
  • Rate Limiting

而且 codex 本身不是浏览器,很容易让人联想到“机器人识别”“非浏览器请求被拦”之类的问题。

2. Cloudflare 回源失败

另一个很自然的怀疑是:

  • Cloudflare 边缘收到请求了
  • 但没法回源到 sub2api
  • 所以源站没日志

这类问题在 Cloudflare 场景下也很常见,比如:

  • 源站端口问题
  • 源站 TLS 证书问题
  • 安全组没放行 Cloudflare IP
  • 回源协议不匹配

3. sub2api 或流式接口不兼容 CDN

因为 codex 的错误里有 stream disconnected before completion,这很容易让人怀疑:

  • 是不是 /responses 这种流式接口和 Cloudflare 的某些行为有冲突
  • 是不是 HTTP/2、buffering、连接保持之类出了问题
  • 是不是流在中间层被断了

4. Full / Full (strict) 相关 SSL 配置

很多人也会下意识把“开 Cloudflare 后 HTTPS 不正常”和“源站没加密/加密有问题”联系起来,怀疑是:

  • Flexible
  • Full
  • Full (strict)

这些模式和源站证书之间存在配置冲突。

这些怀疑都不是没道理,但如果你从一开始就一头扎进这些方向,反而可能会错过真正更关键的线索。

四、这次问题为什么特别迷惑

这次故障最迷惑的地方,不在于问题本身多复杂,而在于它非常擅长伪装。

它伪装成了一个应用层故障:

  • 客户端看到的是请求失败
  • 像是接口服务异常

它又伪装成了一个回源层故障:

  • 源站没有日志
  • 像是请求根本没到服务器

它还伪装成了一个 Cloudflare 规则问题:

  • 开 CDN 才出问题
  • 关 CDN 就恢复
  • 像是某个安全策略命中了请求

更麻烦的是,它还有一个“正常对照组”:

  • 另一个 Cloudflare 账户下的类似域名却是正常的

这就让整个问题更像一种“配置玄学”或者“平台偶发现象”。

但如果从网络层的角度重新看,就会发现有一种可能性恰好能解释所有现象:

请求根本不是被应用拒绝了,而是在变成 HTTP 请求之前就已经失败了。

也就是说,问题发生的位置可能比 WAF、更比回源、更比应用都更早:

客户端与 Cloudflare 边缘的 TLS 握手阶段。

一旦你把视角移到这里,很多原本看起来矛盾的现象就 suddenly 变得合理了。

五、第一步:先验证 Cloudflare 边缘本身是不是正常

遇到这种问题,我后来觉得第一步不应该急着看应用日志,也不应该先怀疑业务代码,而应该先回答一个更基础的问题:

这个域名在 Cloudflare 边缘上到底能不能正常完成最基础的访问?

最方便的测试路径就是 cdn-cgi/trace

于是我分别对两个域名做了测试:

1
2
curl.exe -vk https://api-a.example.com/cdn-cgi/trace
curl.exe -vk https://api-b.example.com/cdn-cgi/trace

结果两个域名都返回正常。

这一步虽然简单,但价值非常高。它至少说明:

  • Cloudflare 边缘证书不是完全失效
  • 这个域名不是整体不可用
  • DNS 不存在致命错误
  • 客户端至少有机会和 Cloudflare 建立某种 HTTPS 连接

也就是说,问题不是“这个域名一挂代理就彻底死掉”。

这一步把排查范围从“整个链路不可用”缩小到了“特定客户端行为或特定协商方式下不可用”。

这是一个非常重要的缩圈。

六、第二步:验证 /responses 路径本身是不是能到应用

既然边缘没彻底坏,下一步自然是看 API 路径是否可达。

我对两个域名都发了一个最基础的 POST /responses 请求,不带 API Key,只是想看是否能至少走到应用层并拿到一个明确的 HTTP 响应:

1
2
curl.exe -vk -X POST "https://api-a.example.com/responses" -H "content-type: application/json" --data "{}"
curl.exe -vk -X POST "https://api-b.example.com/responses" -H "content-type: application/json" --data "{}"

结果这两个域名都返回了类似的业务层响应:

1
{"code":"API_KEY_REQUIRED","message":"API key is required in Authorization header (Bearer scheme), x-api-key header, or x-goog-api-key header"}

状态码是 401 Unauthorized

这个结果对排查思路影响很大,因为它说明:

  • Cloudflare 并不是完全无法把请求转给源站
  • /responses 路径并没有因为代理而失效
  • sub2api 不是“一加 CDN 就彻底坏了”
  • 应用至少在某些请求形态下是完全能收到流量的

换句话说,问题已经不再是“路径有没有”“接口是不是挂了”,而变成了:

为什么同样一个入口,普通 curl 可以请求,codex 却不行?

到这里,问题就从“服务坏了”转变为“客户端行为差异导致的兼容性问题”。

七、开始怀疑真正的差异:TLS、HTTP/2、ALPN、流式行为

当普通 curl 能成功拿到 HTTP 响应,而真实客户端 codex 却失败时,一个很关键的判断是:

codex 和我手工测试的 curl,并不是在完全相同的条件下访问这个域名。

这个“不同条件”可能来自很多地方,比如:

  • TLS 版本不同
  • 是否走 HTTP/2
  • ALPN 协商不同
  • 是否启用了 HTTP/3 / QUIC
  • 是否采用不同的连接复用策略
  • 流式请求和普通短请求行为不同
  • 运行时、代理层、托管环境对 TLS 1.3 的支持程度不同

在很多场景里,我们很容易把“都叫 HTTPS 请求”当成“行为完全一样”。 但实际上,对 CDN、代理、边缘 TLS、连接复用这些东西来说,不同客户端之间差异很大。

这时我重新回去看了两个 Cloudflare 账户的配置差异,终于注意到一个很关键的不同:

  • 正常的对照域名所在账户,最低 TLS 版本比较宽松
  • 出问题的域名所在账户,把 Minimum TLS Version 设成了 TLS 1.3

这一下,整个问题突然有了一个非常有力的怀疑方向。

八、决定性证据:强制指定 TLS 版本测试

仅仅看到配置差异还不够,必须有实验结果来支撑。

于是我分别对两个域名强制指定 TLS 版本进行测试:

1
2
3
curl.exe -vk --tlsv1.2 --tls-max 1.2 https://api-b.example.com/cdn-cgi/trace
curl.exe -vk --tlsv1.2 --tls-max 1.2 https://api-a.example.com/cdn-cgi/trace
curl.exe -vk --tlsv1.3 --tls-max 1.3 https://api-a.example.com/cdn-cgi/trace

结果非常干脆。

对照域名 api-b.example.com

强制使用 TLS 1.2,返回成功:

1
2
3
HTTP/1.1 200 OK
...
tls=TLSv1.2

说明这个域名允许客户端以 TLS 1.2 接入。

问题域名 api-a.example.com

强制使用 TLS 1.2,直接握手失败:

1
2
schannel: next InitializeSecurityContext failed
curl: (35) ...

说明这个域名不接受 TLS 1.2。

同一个问题域名再强制 TLS 1.3

结果恢复正常:

1
2
3
HTTP/1.1 200 OK
...
tls=TLSv1.3

这三条实验结果组合起来,几乎已经是完整证据链了。

它们共同说明:

  • api-a.example.com 实际上被配置成了 TLS 1.3 only
  • api-b.example.com 则允许 TLS 1.2
  • 所以真正的问题不在 sub2api,也不在 /responses 路径,而在于客户端与 Cloudflare 边缘之间的 TLS 协商条件不同

换句话说:

codex 至少在访问 api-a.example.com 时,没有稳定地以 TLS 1.3 完成握手。

于是,连接在握手阶段就被 Cloudflare 边缘拒绝,请求根本没机会进入 HTTP 层。

九、根因:问题发生在“客户端 -> Cloudflare”这一段,不是“Cloudflare -> 源站”

这次故障里最容易让人混淆的一点,是很多人会把下面两件事混在一起:

1. Cloudflare 到源站的加密模式

这个通常受以下配置影响:

  • Flexible
  • Full
  • Full (strict)

它决定的是 Cloudflare 往回源那一段怎么和你的服务器建立连接。

2. 客户端到 Cloudflare 边缘的 TLS 最低版本

这个由 Minimum TLS Version 控制。

它决定的是:客户端至少得支持哪个 TLS 版本,Cloudflare 才愿意让它接入。

这次问题完全是第二种。

不是因为源站没开 HTTPS。 不是因为回源证书无效。 不是因为 Full 模式不对。 而是因为 Cloudflare 边缘入口门槛设得过高了。

这也是为什么它会表现成:

  • 源站没日志
  • 应用没日志
  • WAF 跳过规则看起来也没用
  • 但基础域名某些请求又似乎是通的

因为入口层的协商条件本来就会导致这种“部分请求可达、部分请求根本还没开始”的现象。

十、为什么源站完全没日志

这次事故里,一个非常有价值的经验就是:

没有源站日志,并不一定意味着 Cloudflare 没有回源,也不一定意味着请求被 WAF 吞了。

还有一种更前置的可能性:

请求在建立 HTTPS 的过程中就已经失败了。

具体到这次,链路实际上是这样的:

  1. codex 尝试访问 https://api-a.example.com/responses
  2. 首先需要和 Cloudflare 边缘建立 TLS 连接
  3. 但由于 api-a.example.com 被配置为最低 TLS 1.3,而客户端没有满足这一条件
  4. 所以 TLS 握手阶段直接失败
  5. HTTP 请求根本没有真正发出去
  6. /responses 路径根本还没有被请求
  7. sub2api 当然不会有日志
  8. 源站 access log 也不会有日志

这就是为什么它看起来像“请求被吞了”,但实际上是“请求还没开始”。

这是一个很值得记住的结论:

只要失败发生在 TLS 握手阶段,业务系统通常是什么都看不到的。

十一、为什么 WAF Skip 规则没能解决问题

我一开始其实已经做了不少 Cloudflare 层面的规避工作,比如针对 API 路径配置了 Skip 规则,跳过:

  • Managed Rules
  • Custom Rules
  • Rate Limiting
  • Super Bot Fight
  • Browser Integrity Check
  • 一些旧版防护项

直觉上,这似乎已经足够宽松了。

但问题仍然存在。

后来想明白的关键在于:

WAF 处理的是已经成为 HTTP 请求的流量。

而 TLS 握手发生在 HTTP 之前。 如果客户端连 HTTPS 连接都没建成,那 WAF 根本没有机会“看到”这个请求。

这也是一个非常典型的误区:

“我已经配置 Skip 了,为什么还是不行?”

因为你跳过的是安全规则,不是 TLS 协议门槛。 两者根本不是同一层的东西。

十二、修复:把 Minimum TLS Version 从 1.3 调回 1.2

既然根因已经明确,修复就很直接了。

最终的调整方案是:

  • 把问题域名的 Minimum TLS VersionTLS 1.3 改为 TLS 1.2
  • 同时保留 TLS 1.3 开启

这点非常重要,因为“改成最低 1.2”并不等于“只能使用 1.2”。

真正的含义是:

  • 支持 TLS 1.3 的客户端,依然会优先协商到 TLS 1.3
  • 只能稳定使用 TLS 1.2 的客户端,也可以正常接入

这是一个典型的“兼容性优先但不放弃现代协议”的配置方式。

而对于面向公网、面对多种第三方客户端和运行时的 API 域名来说,这通常也是最稳妥的方案。

十三、为什么不是继续坚持 TLS 1.3 only

从纯“新协议更先进”的角度看,TLS 1.3 确实更现代:

  • 握手更快
  • 协议更简洁
  • 默认更安全
  • 移除了很多旧的算法和协商方式

但问题在于,“更先进”不等于“更适合作为公网 API 的唯一门槛”。

只要你的 API 需要面向:

  • 第三方 SDK
  • 不同语言运行时
  • 各种代理环境
  • 托管平台
  • CLI 工具
  • 远端执行环境

就很难保证所有客户端都始终稳定地使用 TLS 1.3。

有些环境名义上支持 TLS 1.3,实际上:

  • 会回退到 TLS 1.2
  • 在某些网络或中间层下协商不稳定
  • 对 ALPN、HTTP/2、连接复用行为存在差异

于是结果就是: 你以为只是把门槛提高了一点,实际上却是把一部分正常客户端直接拦在门外。

这次 codex 的行为,基本就是一个很典型的例子。

十四、这次事故让我真正学会区分的两件事

这次排查之后,我脑子里对 Cloudflare 的两个概念终于被彻底分开了。

概念一:客户端怎么连 Cloudflare

关键配置项是:

  • Minimum TLS Version
  • TLS 1.3
  • 可能还有 HTTP/2、HTTP/3、ALPN 相关行为

这决定的是:入口能不能建立起来。

概念二:Cloudflare 怎么连我的源站

关键配置项是:

  • Flexible
  • Full
  • Full (strict)
  • 回源证书
  • 回源端口
  • 源站放行策略

这决定的是:Cloudflare 收到请求之后,能不能继续把请求转回我的服务。

这次故障,完全是第一段的问题。 如果一直盯着第二段看,只会越看越困惑。

十五、我最后总结出的排查方法

这次之后,我给自己整理了一套更可靠的排查顺序,尤其适合“开 CDN 后 API 异常,但源站没日志”的场景。

第一步:先打 cdn-cgi/trace

1
curl.exe -vk https://你的域名/cdn-cgi/trace

这一步用来确认:

  • Cloudflare 边缘是否可访问
  • 是否至少进入了 HTTP 层
  • 当前实际协商的 TLS 版本是什么

第二步:再打真实 API 路径

1
curl.exe -vk -X POST "https://你的域名/responses" -H "content-type: application/json" --data "{}"

这一步用来确认:

  • API 路径能不能到应用
  • 是否能至少拿到业务层响应
  • 问题是“接口整体不可达”还是“特定客户端不兼容”

第三步:强制指定 TLS 版本做控制变量实验

1
2
curl.exe -vk --tlsv1.2 --tls-max 1.2 https://你的域名/cdn-cgi/trace
curl.exe -vk --tlsv1.3 --tls-max 1.3 https://你的域名/cdn-cgi/trace

如果一个通、一个不通,基本就能把问题定位到边缘 TLS 配置。

第四步:再去看 WAF、回源、源站日志

只有当前面确认基础握手和基础 HTTP 都没问题时,再去深入看:

  • WAF
  • 回源证书
  • 源站反代
  • 应用日志
  • SSE / buffering / timeout

这样排查会快很多,不容易在错误层级上耗时间。

十六、面向公网 API 的 Cloudflare 配置建议

经过这次踩坑,我现在对这类 API 域名的推荐配置会更保守也更务实一些。

如果是一个要面对多种客户端的公网 API,我会优先这样配:

  • Minimum TLS Version = 1.2
  • TLS 1.3 = On
  • SSL/TLS mode = FullFull (strict)
  • API 路径明确 Bypass Cache
  • 需要的话再对 API 路径配置 WAF Skip
  • 非必要不强制 TLS 1.3 only

原因很简单:

  • 兼容性足够好
  • 安全性依然在现代基线之上
  • 不会因为过度追求“最新协议”而把一部分正常客户端挡掉

对于内部系统、完全受控的客户端环境,当然可以更激进。 但对公网入口来说,很多时候最好的安全策略不是“设得最高”,而是“设得合理”。

十七、这次故障最值得记住的一句话

如果要用一句话总结这次事故,我会写成:

这不是 sub2api 挂了,也不是 Cloudflare WAF 拦了,而是 Cloudflare 边缘把 API 域名配置成了 TLS 1.3 only,导致 codex 请求在 TLS 握手阶段就失败了,请求根本没有进入 HTTP 层。

而如果再进一步提炼经验,那就是:

当一个请求“看起来像没到源站”时,别只盯着源站和 WAF,有时候它甚至还没有成为一个真正的 HTTP 请求。

十八、结语

这次问题让我印象最深的,不是它技术上有多复杂,而是它有多会“伪装”。

它伪装成 API 问题。 它伪装成 Cloudflare 规则问题。 它伪装成回源问题。 它伪装成 sub2api 或流式接口兼容性问题。

但最后,真正的原因只是一个在控制台里看起来并不起眼的配置项。

也正是这种“不起眼”,才最容易让人忽略它。

以后如果再遇到类似现象:

  • 开 CDN 前正常
  • 开 CDN 后失败
  • 源站没日志
  • WAF 没明显拦截
  • 普通请求好像又能通

我会第一时间去看:

  • Minimum TLS Version
  • 客户端实际协商的 TLS 版本
  • 客户端与 Cloudflare 边缘之间的握手是否成功

有些问题,真的不是“请求被拦了”,而是“请求还没开始”。

附录:文中关键命令

1
2
curl.exe -vk https://api-a.example.com/cdn-cgi/trace
curl.exe -vk https://api-b.example.com/cdn-cgi/trace
1
2
curl.exe -vk -X POST "https://api-a.example.com/responses" -H "content-type: application/json" --data "{}"
curl.exe -vk -X POST "https://api-b.example.com/responses" -H "content-type: application/json" --data "{}"
1
2
3
curl.exe -vk --tlsv1.2 --tls-max 1.2 https://api-b.example.com/cdn-cgi/trace
curl.exe -vk --tlsv1.2 --tls-max 1.2 https://api-a.example.com/cdn-cgi/trace
curl.exe -vk --tlsv1.3 --tls-max 1.3 https://api-a.example.com/cdn-cgi/trace