diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index fcdf14e9..3d9e5e0d 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -92,7 +92,7 @@ func (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Aut if err := e.PrepareRequest(httpReq, auth); err != nil { return nil, err } - httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0) return httpClient.Do(httpReq) } @@ -188,7 +188,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r AuthValue: authValue, }) - httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0) httpResp, err := httpClient.Do(httpReq) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) @@ -355,7 +355,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A AuthValue: authValue, }) - httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0) httpResp, err := httpClient.Do(httpReq) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) @@ -522,7 +522,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut AuthValue: authValue, }) - httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0) resp, err := httpClient.Do(httpReq) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) diff --git a/internal/runtime/executor/helps/utls_client.go b/internal/runtime/executor/helps/utls_client.go new file mode 100644 index 00000000..d41a90f3 --- /dev/null +++ b/internal/runtime/executor/helps/utls_client.go @@ -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 +}