204848 commited on
Commit ·
e358bb0
1
Parent(s): c12b13c
feat: 优化HTTP/2支持,调整拨号器以优先使用IPv4并兼容utls指纹
Browse files- docs/web-endpoint-upgrade.md +20 -5
- go.mod +4 -1
- internal/deepseek/hif/hif.go +5 -4
- internal/deepseek/transport/transport.go +50 -28
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 |
-
|
| 326 |
```
|
| 327 |
|
| 328 |
### Web端
|
|
@@ -330,13 +330,20 @@ ForceAttemptHTTP2: true
|
|
| 330 |
```
|
| 331 |
ClientHello: HelloChrome_Auto
|
| 332 |
ALPN: 自然协商 (支持 h2 和 http/1.1)
|
| 333 |
-
|
| 334 |
```
|
| 335 |
|
| 336 |
> chat.deepseek.com 现已要求 HTTP/2:HTTP/1.1 客户端会收到服务端返回的 h2
|
| 337 |
-
> SETTINGS 帧,被
|
| 338 |
-
>
|
| 339 |
-
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
// 拨号器
|
| 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.
|
| 46 |
-
fallback: trans.NewFallbackClientWithPlatform(15*time.Second, trans.
|
| 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 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 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:
|
| 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 |
-
//
|
| 165 |
-
// 没有 IPv6 出口的部署:标准 Happy Eyeballs 在
|
| 166 |
-
// 拨 IPv6 拿到 "network is unreachable"
|
| 167 |
-
|
|
|
|
|
|
|
| 168 |
host, port, err := net.SplitHostPort(addr)
|
| 169 |
if err != nil {
|
| 170 |
return nil, err
|
| 171 |
}
|
| 172 |
-
|
|
|
|
| 173 |
if ip := net.ParseIP(host); ip != nil {
|
|
|
|
| 174 |
if ip.To4() == nil {
|
| 175 |
-
|
|
|
|
|
|
|
| 176 |
}
|
| 177 |
-
d
|
| 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("
|
| 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 |
}
|