背景与根因分析
在通过 NaiveProxy(Caddy forwardproxy)代理访问双栈(IPv4/IPv6,如 Cloudflare 等 CDN 托管的站点与 API 端点)目标时,客户端及上层应用(如 Node.js / Safari / 模型 API SDK)偶发出现以下异常:
- 固定卡在连接超时门限(如 10.0~10.5 秒):
上层运行时(如 Node.js undici)默认连接超时为 10 秒(包含底层 TCP + TLS 握手协商完成)。未完成握手即抛出 Connection Timeout。
- 连接被直接重置 (
ECONNRESET):
客户端发出 TLS ClientHello 后,底层承载的 HTTP/3 双向流被服务端单方面 RST/Close。
经源码审查,定位到服务端 forwardproxy 继承自原版架构的两项历史技术债:
1. 违背 RFC 9110 CONNECT 规范(非标的提前 200 OK / Fast Open)
- 代码位置:
forwardproxy.go:354-365
- 机制缺陷:
服务端在接收到 CONNECT 请求时,尚未向目标服务器发起真实 TCP 拨号,即提前向客户端回写 200 OK 并 Flush。
- 后果:
客户端收到 200 OK 响应后判定代理隧道已通,立即开始上层连接超时倒计时并发送 TLS ClientHello。若服务端此时向目标拨号发生延迟或卡顿,客户端只能阻塞等待直至触发应用层超时;若服务端最终拨号失败,只能直接关闭该 HTTP/3 流,导致客户端应用层遭遇非预期的 ECONNRESET。
2. 违背 RFC 8305 Happy Eyeballs v2 规范(串行阻塞式遍历目标 IP)
- 代码位置:
forwardproxy.go:560-568
- 机制缺陷:
// This is net.Dial's default behavior: if the host resolves to multiple IP addresses,
// Dial will try each IP address in order until one succeeds
for _, address := range resolved.addresses {
conn, err := h.dialContext(ctx, network, address)
if err == nil {
return conn, nil
}
}
服务端为了执行 ACL 过滤,先通过 DNS 解析将域名拆散为单一 IP 列表,随后通过 for ... range 进行按序串行阻塞尝试。
- 后果:
单次连接超时默认高达 30 秒(DialTimeout)。当目标域名解析出多个 IPv6 和 IPv4 地址时,若列表排在首位的 IPv6 出现丢包、高延迟或路由黑洞,串行循环会阻塞在首个 IP 上死等,直接废弃了 Go 标准库原生支持的 RFC 8305(Happy Eyeballs 250ms 并发竞速回退机制)。
修复方案规划
1. 重构目标拨号逻辑(引入 RFC 8305 Happy Eyeballs 双栈竞速)
- 实现目标:彻底消除多 IP 场景下的单线程串行等待。
- 实施步骤:
- 在 ACL 检查通过后,将解析出的目标候选地址分类(IPv4 / IPv6);
- 实现标准 Happy Eyeballs 并发连接池:首选地址发起 SYN,若 250ms 内未成功则并发发起备选地址拨号,最先成功建立 TCP 握手者胜出,未完成的拨号立即取消;
- 将单个 IP 拨号超时合理约束(建议 3~5 秒),整体解析与拨号设置总 deadline;
- 保持 ACL 规则在建连前或建连时的合规拦截能力。
2. 规范 CONNECT 时序状态机(对齐 RFC 9110 Section 9.3.6)
- 实现目标:真实目标 TCP 连接就绪后再确认 200 OK。
- 实施步骤:
- 将
w.WriteHeader(http.StatusOK) 与 Flush 移动至 h.dialContextCheckACL 成功建立真实网络连接之后;
- 若拨号失败,返回符合标准的 HTTP 错误响应码(如
502 Bad Gateway、504 Gateway Timeout),替代粗暴的流 RST;
- 保留并验证对原有客户端 Padding 协商机制的兼容。
3. 测试与回归要求
- 编写包含单栈路由黑洞/高延迟的 Happy Eyeballs 单元测试,验证 250ms 快速回退;
- 保证现有 TCP/UDP 代理套件、ACL 过滤及并发基线回归全绿。
背景与根因分析
在通过 NaiveProxy(Caddy forwardproxy)代理访问双栈(IPv4/IPv6,如 Cloudflare 等 CDN 托管的站点与 API 端点)目标时,客户端及上层应用(如 Node.js / Safari / 模型 API SDK)偶发出现以下异常:
上层运行时(如 Node.js
undici)默认连接超时为 10 秒(包含底层 TCP + TLS 握手协商完成)。未完成握手即抛出 Connection Timeout。ECONNRESET):客户端发出 TLS
ClientHello后,底层承载的 HTTP/3 双向流被服务端单方面 RST/Close。经源码审查,定位到服务端
forwardproxy继承自原版架构的两项历史技术债:1. 违背 RFC 9110 CONNECT 规范(非标的提前 200 OK / Fast Open)
forwardproxy.go:354-365服务端在接收到
CONNECT请求时,尚未向目标服务器发起真实 TCP 拨号,即提前向客户端回写200 OK并 Flush。客户端收到 200 OK 响应后判定代理隧道已通,立即开始上层连接超时倒计时并发送 TLS
ClientHello。若服务端此时向目标拨号发生延迟或卡顿,客户端只能阻塞等待直至触发应用层超时;若服务端最终拨号失败,只能直接关闭该 HTTP/3 流,导致客户端应用层遭遇非预期的ECONNRESET。2. 违背 RFC 8305 Happy Eyeballs v2 规范(串行阻塞式遍历目标 IP)
forwardproxy.go:560-568for ... range进行按序串行阻塞尝试。单次连接超时默认高达 30 秒(
DialTimeout)。当目标域名解析出多个 IPv6 和 IPv4 地址时,若列表排在首位的 IPv6 出现丢包、高延迟或路由黑洞,串行循环会阻塞在首个 IP 上死等,直接废弃了 Go 标准库原生支持的 RFC 8305(Happy Eyeballs 250ms 并发竞速回退机制)。修复方案规划
1. 重构目标拨号逻辑(引入 RFC 8305 Happy Eyeballs 双栈竞速)
2. 规范 CONNECT 时序状态机(对齐 RFC 9110 Section 9.3.6)
w.WriteHeader(http.StatusOK)与Flush移动至h.dialContextCheckACL成功建立真实网络连接之后;502 Bad Gateway、504 Gateway Timeout),替代粗暴的流 RST;3. 测试与回归要求