fix: Claude API 请求使用 utls Chrome TLS 指纹
Claude executor 的 API 请求之前使用 Go 标准库 crypto/tls,JA3 指纹 与真实 Claude Code(Bun/BoringSSL)不匹配,可被 Cloudflare 识别。 - 新增 helps/utls_client.go,封装 utls Chrome 指纹 + HTTP/2 + 代理支持 - Claude executor 的 4 处 NewProxyAwareHTTPClient 替换为 NewUtlsHTTPClient - 其他 executor(Gemini/Codex/iFlow 等)不受影响,仍用标准 TLS - 非 HTTPS 请求自动回退到标准 transport
This commit is contained in:
@@ -92,7 +92,7 @@ func (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0)
|
||||||
return httpClient.Do(httpReq)
|
return httpClient.Do(httpReq)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
AuthValue: authValue,
|
AuthValue: authValue,
|
||||||
})
|
})
|
||||||
|
|
||||||
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0)
|
||||||
httpResp, err := httpClient.Do(httpReq)
|
httpResp, err := httpClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
||||||
@@ -355,7 +355,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
AuthValue: authValue,
|
AuthValue: authValue,
|
||||||
})
|
})
|
||||||
|
|
||||||
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0)
|
||||||
httpResp, err := httpClient.Do(httpReq)
|
httpResp, err := httpClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
||||||
@@ -522,7 +522,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
AuthValue: authValue,
|
AuthValue: authValue,
|
||||||
})
|
})
|
||||||
|
|
||||||
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0)
|
||||||
resp, err := httpClient.Do(httpReq)
|
resp, err := httpClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package helps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tls "github.com/refraction-networking/utls"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// utlsRoundTripper implements http.RoundTripper using utls with Chrome fingerprint
|
||||||
|
// to bypass Cloudflare's TLS fingerprinting on Anthropic domains.
|
||||||
|
type utlsRoundTripper struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
connections map[string]*http2.ClientConn
|
||||||
|
pending map[string]*sync.Cond
|
||||||
|
dialer proxy.Dialer
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUtlsRoundTripper(proxyURL string) *utlsRoundTripper {
|
||||||
|
var dialer proxy.Dialer = proxy.Direct
|
||||||
|
if proxyURL != "" {
|
||||||
|
proxyDialer, mode, errBuild := proxyutil.BuildDialer(proxyURL)
|
||||||
|
if errBuild != nil {
|
||||||
|
log.Errorf("utls: failed to configure proxy dialer for %q: %v", proxyURL, errBuild)
|
||||||
|
} else if mode != proxyutil.ModeInherit && proxyDialer != nil {
|
||||||
|
dialer = proxyDialer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &utlsRoundTripper{
|
||||||
|
connections: make(map[string]*http2.ClientConn),
|
||||||
|
pending: make(map[string]*sync.Cond),
|
||||||
|
dialer: dialer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *utlsRoundTripper) getOrCreateConnection(host, addr string) (*http2.ClientConn, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
|
||||||
|
if h2Conn, ok := t.connections[host]; ok && h2Conn.CanTakeNewRequest() {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return h2Conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cond, ok := t.pending[host]; ok {
|
||||||
|
cond.Wait()
|
||||||
|
if h2Conn, ok := t.connections[host]; ok && h2Conn.CanTakeNewRequest() {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return h2Conn, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cond := sync.NewCond(&t.mu)
|
||||||
|
t.pending[host] = cond
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
h2Conn, err := t.createConnection(host, addr)
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
delete(t.pending, host)
|
||||||
|
cond.Broadcast()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
t.connections[host] = h2Conn
|
||||||
|
return h2Conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *utlsRoundTripper) createConnection(host, addr string) (*http2.ClientConn, error) {
|
||||||
|
conn, err := t.dialer.Dial("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsConfig := &tls.Config{ServerName: host}
|
||||||
|
tlsConn := tls.UClient(conn, tlsConfig, tls.HelloChrome_Auto)
|
||||||
|
|
||||||
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tr := &http2.Transport{}
|
||||||
|
h2Conn, err := tr.NewClientConn(tlsConn)
|
||||||
|
if err != nil {
|
||||||
|
tlsConn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return h2Conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
host := req.URL.Host
|
||||||
|
addr := host
|
||||||
|
if !strings.Contains(addr, ":") {
|
||||||
|
addr += ":443"
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname := req.URL.Hostname()
|
||||||
|
|
||||||
|
h2Conn, err := t.getOrCreateConnection(hostname, addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h2Conn.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
t.mu.Lock()
|
||||||
|
if cached, ok := t.connections[hostname]; ok && cached == h2Conn {
|
||||||
|
delete(t.connections, hostname)
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallbackRoundTripper tries utls first; if the target is plain HTTP or a
|
||||||
|
// non-HTTPS scheme it falls back to a standard transport.
|
||||||
|
type fallbackRoundTripper struct {
|
||||||
|
utls *utlsRoundTripper
|
||||||
|
fallback http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fallbackRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.Scheme == "https" {
|
||||||
|
return f.utls.RoundTrip(req)
|
||||||
|
}
|
||||||
|
return f.fallback.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUtlsHTTPClient creates an HTTP client using utls Chrome TLS fingerprint.
|
||||||
|
// Use this for Claude API requests to match real Claude Code's TLS behavior.
|
||||||
|
// Falls back to standard transport for non-HTTPS requests.
|
||||||
|
func NewUtlsHTTPClient(cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
|
||||||
|
var proxyURL string
|
||||||
|
if auth != nil {
|
||||||
|
proxyURL = strings.TrimSpace(auth.ProxyURL)
|
||||||
|
}
|
||||||
|
if proxyURL == "" && cfg != nil {
|
||||||
|
proxyURL = strings.TrimSpace(cfg.ProxyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
utlsRT := newUtlsRoundTripper(proxyURL)
|
||||||
|
|
||||||
|
var standardTransport http.RoundTripper = &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
}
|
||||||
|
if proxyURL != "" {
|
||||||
|
if transport := buildProxyTransport(proxyURL); transport != nil {
|
||||||
|
standardTransport = transport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &fallbackRoundTripper{
|
||||||
|
utls: utlsRT,
|
||||||
|
fallback: standardTransport,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if timeout > 0 {
|
||||||
|
client.Timeout = timeout
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user