204848 commited on
Commit
e358bb0
·
1 Parent(s): c12b13c

feat: 优化HTTP/2支持,调整拨号器以优先使用IPv4并兼容utls指纹

Browse files
docs/web-endpoint-upgrade.md CHANGED
@@ -322,7 +322,7 @@ func (p *HIFPoller) GetHeaders() map[string]string // 获取当前HIF头部
322
  ```
323
  ClientHello: HelloSafari_Auto
324
  ALPN: h2 + http/1.1 (setSafariALPN,与真实 Safari 一致)
325
- ForceAttemptHTTP2: true
326
  ```
327
 
328
  ### Web端
@@ -330,13 +330,20 @@ ForceAttemptHTTP2: true
330
  ```
331
  ClientHello: HelloChrome_Auto
332
  ALPN: 自然协商 (支持 h2 和 http/1.1)
333
- ForceAttemptHTTP2: true
334
  ```
335
 
336
  > chat.deepseek.com 现已要求 HTTP/2:HTTP/1.1 客户端会收到服务端返回的 h2
337
- > SETTINGS 帧,被 Go 的 http.Transport 解析为 "malformed HTTP response"。
338
- > 因此两个平台均通过 ALPN 协商 h2 并 `ForceAttemptHTTP2: true`。Android 端
339
- > 早期曾强制 http/1.1现已对齐真实 Safari 行为改用 h2。
 
 
 
 
 
 
 
340
 
341
  实现位于 `internal/deepseek/transport/transport.go`:
342
 
@@ -347,6 +354,14 @@ func TLSDialerForPlatform(platform string) func(...) {
347
  }
348
  return safariTLSDialer(dialContext) // Safari + HTTP/2
349
  }
 
 
 
 
 
 
 
 
350
  ```
351
 
352
  ---
 
322
  ```
323
  ClientHello: HelloSafari_Auto
324
  ALPN: h2 + http/1.1 (setSafariALPN,与真实 Safari 一致)
325
+ 传输层: golang.org/x/net/http2.Transport
326
  ```
327
 
328
  ### Web端
 
330
  ```
331
  ClientHello: HelloChrome_Auto
332
  ALPN: 自然协商 (支持 h2 和 http/1.1)
333
+ 传输层: golang.org/x/net/http2.Transport
334
  ```
335
 
336
  > chat.deepseek.com 现已要求 HTTP/2:HTTP/1.1 客户端会收到服务端返回的 h2
337
+ > SETTINGS 帧,被解析为 "malformed HTTP response"。
338
+ >
339
+ > **关键实现要点**:utls 连接(`*utls.UConn`)不是 `*tls.Conn`Go 标准库的
340
+ > `http.Transport` 无法识别其协商出的 ALPN `h2` 并升级 HTTP/2(只会走
341
+ > HTTP/1.1)。因此带 utls 指纹的客户端改用 `golang.org/x/net/http2.Transport`
342
+ > + `DialTLSContext`(返回 utls 连接):`http2.Transport` 直接对任意
343
+ > `net.Conn` 做 h2 帧收发,不依赖 `*tls.Conn`,从而让 utls 指纹 + h2 共存。
344
+ > 回退的 std TLS 客户端则用标准 `http.Transport` + 显式
345
+ > `http2.ConfigureTransport` 保证 h2。SOCKS5 代理通过 `dialContext` 在 TCP
346
+ > 层透传,对 h2 透明。
347
 
348
  实现位于 `internal/deepseek/transport/transport.go`:
349
 
 
354
  }
355
  return safariTLSDialer(dialContext) // Safari + HTTP/2
356
  }
357
+
358
+ // utls 客户端用 http2.Transport 驱动 h2
359
+ func NewWithDialContextAndPlatform(...) *Client {
360
+ h2t := &http2.Transport{
361
+ DialTLSContext: func(...) { return tlsDialer(...) },
362
+ }
363
+ return &Client{http: &http.Client{Transport: h2t}}
364
+ }
365
  ```
366
 
367
  ---
go.mod CHANGED
@@ -11,7 +11,10 @@ require (
11
  github.com/router-for-me/CLIProxyAPI/v6 v6.9.14
12
  )
13
 
14
- require github.com/dlclark/regexp2 v1.11.5 // indirect
 
 
 
15
 
16
  require (
17
  github.com/klauspost/compress v1.18.5 // indirect
 
11
  github.com/router-for-me/CLIProxyAPI/v6 v6.9.14
12
  )
13
 
14
+ require (
15
+ github.com/dlclark/regexp2 v1.11.5 // indirect
16
+ golang.org/x/text v0.35.0 // indirect
17
+ )
18
 
19
  require (
20
  github.com/klauspost/compress v1.18.5 // indirect
internal/deepseek/hif/hif.go CHANGED
@@ -37,13 +37,14 @@ type HIFPoller struct {
37
  }
38
 
39
  // NewHIFPoller creates a new HIFPoller using web-platform TLS fingerprinting.
40
- // 拨号器强制 IPv4:HuggingFace Spaces 等环境没有 IPv6 出口,
41
- // hif-dliq/hif-leim 常解析到 IPv6,会导致 "network is unreachable" 反复重试
 
42
  func NewHIFPoller() *HIFPoller {
43
  return &HIFPoller{
44
  stopCh: make(chan struct{}),
45
- client: trans.NewWithDialContextAndPlatform(15*time.Second, trans.IPv4DialContext, "web"),
46
- fallback: trans.NewFallbackClientWithPlatform(15*time.Second, trans.IPv4DialContext, "web"),
47
  }
48
  }
49
 
 
37
  }
38
 
39
  // NewHIFPoller creates a new HIFPoller using web-platform TLS fingerprinting.
40
+ // 拨号器优先 IPv4:HuggingFace Spaces 等环境没有 IPv6 出口,
41
+ // hif-dliq/hif-leim 常解析到 IPv6,会导致 "network is unreachable" 反复重试
42
+ // 但 hif-dliq 仅 IPv6,故优先 IPv4、兜底 IPv6 而非完全跳过。
43
  func NewHIFPoller() *HIFPoller {
44
  return &HIFPoller{
45
  stopCh: make(chan struct{}),
46
+ client: trans.NewWithDialContextAndPlatform(15*time.Second, trans.PreferIPv4DialContext, "web"),
47
+ fallback: trans.NewFallbackClientWithPlatform(15*time.Second, trans.PreferIPv4DialContext, "web"),
48
  }
49
  }
50
 
internal/deepseek/transport/transport.go CHANGED
@@ -8,6 +8,8 @@ import (
8
  "net/http"
9
  "time"
10
 
 
 
11
  utls "github.com/refraction-networking/utls"
12
  )
13
 
@@ -33,27 +35,26 @@ func NewWithPlatform(timeout time.Duration, platform string) *Client {
33
  return NewWithDialContextAndPlatform(timeout, nil, platform)
34
  }
35
 
 
 
 
 
 
 
 
 
36
  func NewWithDialContextAndPlatform(timeout time.Duration, dialContext DialContextFunc, platform string) *Client {
37
- useEnvProxy := dialContext == nil
38
  if dialContext == nil {
39
  dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
40
  }
41
  tlsDialer := TLSDialerForPlatform(platform, dialContext)
42
- // chat.deepseek.com 现已要求 HTTP/2(HTTP/1.1 会被服务端返回的 h2 SETTINGS 帧
43
- // 击穿为 "malformed HTTP response"),因此所有平台都强制尝试 h2。
44
- base := &http.Transport{
45
- ForceAttemptHTTP2: true,
46
- MaxIdleConns: 200,
47
- MaxIdleConnsPerHost: 100,
48
- IdleConnTimeout: 90 * time.Second,
49
- DialContext: dialContext,
50
- DialTLSContext: tlsDialer,
51
- TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
52
- }
53
- if useEnvProxy {
54
- base.Proxy = http.ProxyFromEnvironment
55
  }
56
- return &Client{http: &http.Client{Timeout: timeout, Transport: base}}
57
  }
58
 
59
  func (c *Client) Do(req *http.Request) (*http.Response, error) {
@@ -64,13 +65,15 @@ func NewFallbackClient(timeout time.Duration, dialContext DialContextFunc) *http
64
  return NewFallbackClientWithPlatform(timeout, dialContext, "android")
65
  }
66
 
 
 
 
 
67
  func NewFallbackClientWithPlatform(timeout time.Duration, dialContext DialContextFunc, platform string) *http.Client {
68
  useEnvProxy := dialContext == nil
69
  if dialContext == nil {
70
  dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
71
  }
72
- // 同主 transport:所有平台强制尝试 h2,保证 utls 指纹请求失败回退到 std
73
- // transport 时仍能与要求 HTTP/2 的 chat.deepseek.com 通信。
74
  base := &http.Transport{
75
  ForceAttemptHTTP2: true,
76
  MaxIdleConns: 200,
@@ -82,6 +85,10 @@ func NewFallbackClientWithPlatform(timeout time.Duration, dialContext DialContex
82
  if useEnvProxy {
83
  base.Proxy = http.ProxyFromEnvironment
84
  }
 
 
 
 
85
  return &http.Client{Timeout: timeout, Transport: base}
86
  }
87
 
@@ -108,7 +115,6 @@ func chromeTLSDialer(dialContext DialContextFunc) func(ctx context.Context, netw
108
  host, _, _ := net.SplitHostPort(addr)
109
  uCfg := &utls.Config{ServerName: host}
110
  uConn := utls.UClient(plainConn, uCfg, utls.HelloChrome_Auto)
111
- // Do NOT force HTTP/1.1 ALPN; allow natural ALPN negotiation (h2 or http/1.1).
112
  err = uConn.HandshakeContext(ctx)
113
  if err != nil {
114
  _ = plainConn.Close()
@@ -161,28 +167,33 @@ func setSafariALPN(uConn *utls.UConn) error {
161
  return nil
162
  }
163
 
164
- // IPv4DialContext 拨号解析到的 IPv4 地址。用于 HuggingFace Spaces
165
- // 没有 IPv6 出口的部署:标准 Happy Eyeballs 在某些纯 IPv6 解析结果下会反复
166
- // 拨 IPv6 拿到 "network is unreachable",这里显式跳过 IPv6
167
- func IPv4DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
 
 
168
  host, port, err := net.SplitHostPort(addr)
169
  if err != nil {
170
  return nil, err
171
  }
172
- // 已经是 IP 直连时,IPv4 直接拨,IPv6 直接放弃。
 
173
  if ip := net.ParseIP(host); ip != nil {
 
174
  if ip.To4() == nil {
175
- return nil, fmt.Errorf("ipv4-only dialer: %s is not IPv4", host)
 
 
176
  }
177
- d := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
178
- return d.DialContext(ctx, "tcp4", net.JoinHostPort(host, port))
179
  }
180
  ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
181
  if err != nil {
182
  return nil, err
183
  }
 
184
  var lastErr error
185
- d := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
186
  for _, ip := range ips {
187
  if ip.IP.To4() == nil {
188
  continue
@@ -193,8 +204,19 @@ func IPv4DialContext(ctx context.Context, network, addr string) (net.Conn, error
193
  }
194
  lastErr = derr
195
  }
 
 
 
 
 
 
 
 
 
 
 
196
  if lastErr != nil {
197
  return nil, lastErr
198
  }
199
- return nil, fmt.Errorf("ipv4-only dialer: no IPv4 address resolved for %s", host)
200
  }
 
8
  "net/http"
9
  "time"
10
 
11
+ "golang.org/x/net/http2"
12
+
13
  utls "github.com/refraction-networking/utls"
14
  )
15
 
 
35
  return NewWithDialContextAndPlatform(timeout, nil, platform)
36
  }
37
 
38
+ // NewWithDialContextAndPlatform 构建带 utls 指纹的客户端。
39
+ //
40
+ // 关键点:utls 连接(*utls.UConn)不是 *tls.Conn,Go 的 http.Transport 无法
41
+ // 识别它协商出的 ALPN "h2" 并升级 HTTP/2,只能走 HTTP/1.1。而 chat.deepseek.com
42
+ // 现已要求 HTTP/2,HTTP/1.1 会被服务端返回的 h2 SETTINGS 帧击穿为
43
+ // "malformed HTTP response"。因此这里改用 golang.org/x/net/http2.Transport,
44
+ // 它直接对任意 net.Conn 做 h2 帧收发,不依赖 *tls.Conn,从而让 utls 指纹 + h2
45
+ // 共存。SOCKS5 代理仍通过 dialContext 在 TCP 层透传,不受影响。
46
  func NewWithDialContextAndPlatform(timeout time.Duration, dialContext DialContextFunc, platform string) *Client {
 
47
  if dialContext == nil {
48
  dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
49
  }
50
  tlsDialer := TLSDialerForPlatform(platform, dialContext)
51
+ h2t := &http2.Transport{
52
+ AllowHTTP: false,
53
+ DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
54
+ return tlsDialer(ctx, network, addr)
55
+ },
 
 
 
 
 
 
 
 
56
  }
57
+ return &Client{http: &http.Client{Timeout: timeout, Transport: h2t}}
58
  }
59
 
60
  func (c *Client) Do(req *http.Request) (*http.Response, error) {
 
65
  return NewFallbackClientWithPlatform(timeout, dialContext, "android")
66
  }
67
 
68
+ // NewFallbackClientWithPlatform 构建 std TLS 回退客户端。它使用标准 *tls.Conn,
69
+ // 通过显式 http2.ConfigureTransport 确保 HTTP/2 可用(ForceAttemptHTTP2 在某些
70
+ // 场景下不会自动配置 h2,显式调用更稳妥)。std transport 同时支持 HTTP CONNECT
71
+ // 代理(Proxy)与 SOCKS5(dialContext)。
72
  func NewFallbackClientWithPlatform(timeout time.Duration, dialContext DialContextFunc, platform string) *http.Client {
73
  useEnvProxy := dialContext == nil
74
  if dialContext == nil {
75
  dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
76
  }
 
 
77
  base := &http.Transport{
78
  ForceAttemptHTTP2: true,
79
  MaxIdleConns: 200,
 
85
  if useEnvProxy {
86
  base.Proxy = http.ProxyFromEnvironment
87
  }
88
+ if err := http2.ConfigureTransport(base); err != nil {
89
+ // 配置 h2 失败不致命:仍能以 HTTP/1.1 退化运行。
90
+ _ = err
91
+ }
92
  return &http.Client{Timeout: timeout, Transport: base}
93
  }
94
 
 
115
  host, _, _ := net.SplitHostPort(addr)
116
  uCfg := &utls.Config{ServerName: host}
117
  uConn := utls.UClient(plainConn, uCfg, utls.HelloChrome_Auto)
 
118
  err = uConn.HandshakeContext(ctx)
119
  if err != nil {
120
  _ = plainConn.Close()
 
167
  return nil
168
  }
169
 
170
+ // PreferIPv4DialContext 优先拨号 IPv4 地址,没有 IPv4 时回退到 IPv6。
171
+ // 用于 HuggingFace Spaces 等没有 IPv6 出口的部署:标准 Happy Eyeballs 在
172
+ // 纯 IPv6 解析结果下会反复拨 IPv6 拿到 "network is unreachable"。但部分域名
173
+ // (如 hif-dliq.deepseek.com)只有 IPv6,因此不能完全跳过 IPv6——优先 IPv4、
174
+ // 兜底 IPv6。
175
+ func PreferIPv4DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
176
  host, port, err := net.SplitHostPort(addr)
177
  if err != nil {
178
  return nil, err
179
  }
180
+ d := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
181
+ // 已经是 IP 直连时直接拨。
182
  if ip := net.ParseIP(host); ip != nil {
183
+ nw := "tcp"
184
  if ip.To4() == nil {
185
+ nw = "tcp6"
186
+ } else {
187
+ nw = "tcp4"
188
  }
189
+ return d.DialContext(ctx, nw, net.JoinHostPort(host, port))
 
190
  }
191
  ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
192
  if err != nil {
193
  return nil, err
194
  }
195
+ // 第一轮:IPv4。
196
  var lastErr error
 
197
  for _, ip := range ips {
198
  if ip.IP.To4() == nil {
199
  continue
 
204
  }
205
  lastErr = derr
206
  }
207
+ // 第二轮:IPv6 兜底。
208
+ for _, ip := range ips {
209
+ if ip.IP.To4() != nil {
210
+ continue
211
+ }
212
+ conn, derr := d.DialContext(ctx, "tcp6", net.JoinHostPort(ip.IP.String(), port))
213
+ if derr == nil {
214
+ return conn, nil
215
+ }
216
+ lastErr = derr
217
+ }
218
  if lastErr != nil {
219
  return nil, lastErr
220
  }
221
+ return nil, fmt.Errorf("no usable address resolved for %s", host)
222
  }