diff --git a/internal/auth/claude/anthropic_auth.go b/internal/auth/claude/anthropic_auth.go index 2853e418e6..88e1fadc07 100644 --- a/internal/auth/claude/anthropic_auth.go +++ b/internal/auth/claude/anthropic_auth.go @@ -50,19 +50,26 @@ type ClaudeAuth struct { } // NewClaudeAuth creates a new Anthropic authentication service. -// It initializes the HTTP client with a custom TLS transport that uses Firefox -// fingerprint to bypass Cloudflare's TLS fingerprinting on Anthropic domains. +// It initializes the HTTP client with a custom TLS transport that uses Bun BoringSSL +// fingerprint to match real Claude Code CLI behavior. +// +// An optional proxyURL may be provided to override the proxy in cfg.SDKConfig. +// This allows callers to pass a pre-resolved proxy URL (e.g. from ResolveProxyURL) +// so that both request and refresh paths share the same proxy priority logic. // // Parameters: // - cfg: The application configuration containing proxy settings +// - proxyURL: Optional pre-resolved proxy URL that overrides cfg.SDKConfig.ProxyURL // // Returns: // - *ClaudeAuth: A new Claude authentication service instance -func NewClaudeAuth(cfg *config.Config) *ClaudeAuth { - // Use custom HTTP client with Firefox TLS fingerprint to bypass - // Cloudflare's bot detection on Anthropic domains +func NewClaudeAuth(cfg *config.Config, proxyURL ...string) *ClaudeAuth { + pURL := cfg.SDKConfig.ProxyURL + if len(proxyURL) > 0 && proxyURL[0] != "" { + pURL = proxyURL[0] + } return &ClaudeAuth{ - httpClient: NewAnthropicHttpClient(&cfg.SDKConfig), + httpClient: NewAnthropicHttpClient(pURL), } } diff --git a/internal/auth/claude/bun_tls_spec.go b/internal/auth/claude/bun_tls_spec.go new file mode 100644 index 0000000000..803730819a --- /dev/null +++ b/internal/auth/claude/bun_tls_spec.go @@ -0,0 +1,131 @@ +// Bun v1.3.x (BoringSSL) TLS ClientHello Spec for utls +// Captured from real Claude Code 2.1.71 / Bun v1.3.8 via tls.peet.ws +// +// JA3 Hash: 50027c67d7d68e24c00d233bca146d88 +// JA3: 771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49161-49171-49162-49172-156-157-47-53,0-65037-23-65281-10-11-35-16-5-13-18-51-45-43-21,29-23-24,0 +// JA4: t13d1715h1_5b57614c22b0_7baf387fc6ff +// +// Key differences from Chrome: +// - No GREASE (Chrome injects random GREASE values) +// - ALPN: http/1.1 only (Chrome: h2 + http/1.1) +// - Fewer extensions (no compress_certificate, no delegated_credentials) +// - ECH extension 65037 present (BoringSSL-specific) + +package claude + +import ( + tls "github.com/refraction-networking/utls" +) + +// BunBoringSSLSpec returns a utls ClientHelloSpec that exactly matches +// Bun v1.3.x's BoringSSL TLS fingerprint, as used by Claude Code CLI. +// +// This ensures TLS fingerprint consistency between API requests and +// token refresh, matching what Anthropic sees from real Claude Code users. +func BunBoringSSLSpec() *tls.ClientHelloSpec { + return &tls.ClientHelloSpec{ + TLSVersMin: tls.VersionTLS12, + TLSVersMax: tls.VersionTLS13, + CipherSuites: []uint16{ + // TLS 1.3 suites + tls.TLS_AES_128_GCM_SHA256, // 0x1301 + tls.TLS_AES_256_GCM_SHA384, // 0x1302 + tls.TLS_CHACHA20_POLY1305_SHA256, // 0x1303 + // TLS 1.2 ECDHE suites + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, // 0xC02B + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, // 0xC02F + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, // 0xC02C + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, // 0xC030 + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, // 0xCCA9 + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, // 0xCCA8 + tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, // 0xC009 + tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, // 0xC013 + tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, // 0xC00A + tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, // 0xC014 + // TLS 1.2 RSA suites + tls.TLS_RSA_WITH_AES_128_GCM_SHA256, // 0x009C + tls.TLS_RSA_WITH_AES_256_GCM_SHA384, // 0x009D + tls.TLS_RSA_WITH_AES_128_CBC_SHA, // 0x002F + tls.TLS_RSA_WITH_AES_256_CBC_SHA, // 0x0035 + }, + Extensions: []tls.TLSExtension{ + // 0 - Server Name Indication + &tls.SNIExtension{}, + + // 65037 - Encrypted Client Hello (BoringSSL-specific) + &tls.GenericExtension{Id: 65037}, + + // 23 - Extended Master Secret + &tls.ExtendedMasterSecretExtension{}, + + // 65281 - Renegotiation Info + &tls.RenegotiationInfoExtension{Renegotiation: tls.RenegotiateOnceAsClient}, + + // 10 - Supported Groups + &tls.SupportedCurvesExtension{ + Curves: []tls.CurveID{ + tls.X25519, // 29 + tls.CurveP256, // 23 + tls.CurveP384, // 24 + }, + }, + + // 11 - EC Point Formats + &tls.SupportedPointsExtension{ + SupportedPoints: []byte{0x00}, // uncompressed + }, + + // 35 - Session Ticket + &tls.SessionTicketExtension{}, + + // 16 - ALPN (http/1.1 ONLY — Bun does not negotiate h2) + &tls.ALPNExtension{ + AlpnProtocols: []string{"http/1.1"}, + }, + + // 5 - Status Request (OCSP) + &tls.StatusRequestExtension{}, + + // 13 - Signature Algorithms + &tls.SignatureAlgorithmsExtension{ + SupportedSignatureAlgorithms: []tls.SignatureScheme{ + tls.ECDSAWithP256AndSHA256, // 0x0403 + tls.PSSWithSHA256, // 0x0804 + tls.PKCS1WithSHA256, // 0x0401 + tls.ECDSAWithP384AndSHA384, // 0x0503 + tls.PSSWithSHA384, // 0x0805 + tls.PKCS1WithSHA384, // 0x0501 + tls.PSSWithSHA512, // 0x0806 + tls.PKCS1WithSHA512, // 0x0601 + tls.PKCS1WithSHA1, // 0x0201 + }, + }, + + // 18 - Signed Certificate Timestamp (SCT) + &tls.SCTExtension{}, + + // 51 - Key Share (X25519) + &tls.KeyShareExtension{ + KeyShares: []tls.KeyShare{ + {Group: tls.X25519}, + }, + }, + + // 45 - PSK Key Exchange Modes + &tls.PSKKeyExchangeModesExtension{ + Modes: []uint8{tls.PskModeDHE}, // psk_dhe_ke (1) + }, + + // 43 - Supported Versions + &tls.SupportedVersionsExtension{ + Versions: []uint16{ + tls.VersionTLS13, // 0x0304 + tls.VersionTLS12, // 0x0303 + }, + }, + + // 21 - Padding + &tls.UtlsPaddingExtension{GetPaddingLen: tls.BoringPaddingStyle}, + }, + } +} diff --git a/internal/auth/claude/proxy_dial.go b/internal/auth/claude/proxy_dial.go new file mode 100644 index 0000000000..4a5d873e83 --- /dev/null +++ b/internal/auth/claude/proxy_dial.go @@ -0,0 +1,202 @@ +// Package claude provides authentication functionality for Anthropic's Claude API. +// This file implements proxy tunneling (SOCKS5, HTTP/HTTPS CONNECT, env fallback) +// for establishing raw TCP connections through proxies before the TLS handshake. +package claude + +import ( + "bufio" + "context" + stdtls "crypto/tls" + "encoding/base64" + "fmt" + "net" + "net/http" + "net/url" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil" + log "github.com/sirupsen/logrus" + "golang.org/x/net/proxy" +) + +// ProxyDialer manages proxy resolution and TCP connection establishment. +// It supports SOCKS5, HTTP CONNECT, and HTTPS CONNECT proxies, as well as +// environment-variable-based proxy configuration (HTTPS_PROXY, HTTP_PROXY, ALL_PROXY). +type ProxyDialer struct { + proxyURL *url.URL // explicit proxy URL (nil = check env per-request) + proxyMode proxyutil.Mode // inherit (use env), direct, proxy, or invalid +} + +// NewProxyDialer creates a ProxyDialer from a proxy URL string. +// An empty string means inherit proxy from environment variables. +func NewProxyDialer(proxyURL string) *ProxyDialer { + d := &ProxyDialer{proxyMode: proxyutil.ModeInherit} + + if proxyURL != "" { + setting, errParse := proxyutil.Parse(proxyURL) + if errParse != nil { + log.Errorf("failed to parse proxy URL %q: %v", proxyURL, errParse) + } else { + d.proxyMode = setting.Mode + d.proxyURL = setting.URL + } + } + + return d +} + +// resolveProxy returns the effective proxy URL for a given target host. +// For explicit proxy configuration, it returns the configured URL directly. +// For inherit mode, it delegates to http.ProxyFromEnvironment which correctly +// handles HTTPS_PROXY, HTTP_PROXY, NO_PROXY (including CIDR and wildcards). +func (d *ProxyDialer) resolveProxy(targetHost string) *url.URL { + switch d.proxyMode { + case proxyutil.ModeDirect: + return nil + case proxyutil.ModeProxy: + return d.proxyURL + default: + // ModeInherit: delegate to Go's standard proxy resolution which + // reads HTTPS_PROXY, HTTP_PROXY, ALL_PROXY and respects NO_PROXY. + req := &http.Request{URL: &url.URL{Scheme: "https", Host: targetHost}} + proxyURL, _ := http.ProxyFromEnvironment(req) + return proxyURL + } +} + +// DialContext establishes a raw TCP connection to addr, tunneling through a proxy +// if one is configured. It dispatches to direct dialing, SOCKS5, or HTTP CONNECT +// based on the resolved proxy configuration. +func (d *ProxyDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + + proxyURL := d.resolveProxy(host) + + switch { + case proxyURL == nil: + return (&net.Dialer{}).DialContext(ctx, "tcp", addr) + case proxyURL.Scheme == "socks5" || proxyURL.Scheme == "socks5h": + return dialViaSocks5(ctx, proxyURL, addr) + case proxyURL.Scheme == "http" || proxyURL.Scheme == "https": + return dialViaHTTPConnect(ctx, proxyURL, addr) + default: + return nil, fmt.Errorf("unsupported proxy scheme: %s", proxyURL.Scheme) + } +} + +// dialViaSocks5 establishes a TCP connection through a SOCKS5 proxy. +func dialViaSocks5(ctx context.Context, proxyURL *url.URL, targetAddr string) (net.Conn, error) { + var auth *proxy.Auth + if proxyURL.User != nil { + username := proxyURL.User.Username() + password, _ := proxyURL.User.Password() + auth = &proxy.Auth{User: username, Password: password} + } + dialer, err := proxy.SOCKS5("tcp", proxyURL.Host, auth, proxy.Direct) + if err != nil { + return nil, fmt.Errorf("create SOCKS5 dialer: %w", err) + } + if cd, ok := dialer.(proxy.ContextDialer); ok { + return cd.DialContext(ctx, "tcp", targetAddr) + } + return dialer.Dial("tcp", targetAddr) +} + +// dialViaHTTPConnect establishes a TCP tunnel through an HTTP proxy using CONNECT. +// The proxy connection itself is plain TCP (for http:// proxies) or TLS (for https://). +func dialViaHTTPConnect(ctx context.Context, proxyURL *url.URL, targetAddr string) (net.Conn, error) { + proxyAddr := proxyURL.Host + // Ensure the proxy address has a port; default to 80/443 based on scheme. + if _, _, err := net.SplitHostPort(proxyAddr); err != nil { + if proxyURL.Scheme == "https" { + proxyAddr = net.JoinHostPort(proxyAddr, "443") + } else { + proxyAddr = net.JoinHostPort(proxyAddr, "80") + } + } + + // Connect to the proxy. + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", proxyAddr) + if err != nil { + return nil, fmt.Errorf("dial proxy %s: %w", proxyAddr, err) + } + + // HTTPS proxies require a TLS handshake with the proxy itself before + // sending the CONNECT request. We use standard crypto/tls here (not utls) + // because this is the proxy connection — fingerprint mimicry is only + // needed for the final connection to api.anthropic.com. + if proxyURL.Scheme == "https" { + proxyHost := proxyURL.Hostname() + tlsConn := stdtls.Client(conn, &stdtls.Config{ServerName: proxyHost}) + if err := tlsConn.HandshakeContext(ctx); err != nil { + conn.Close() + return nil, fmt.Errorf("TLS to proxy %s: %w", proxyAddr, err) + } + conn = tlsConn + } + + // Propagate context deadline to the CONNECT handshake so it cannot hang + // indefinitely if the proxy accepts TCP but never responds. + if deadline, ok := ctx.Deadline(); ok { + conn.SetDeadline(deadline) + defer conn.SetDeadline(time.Time{}) + } + + // Send CONNECT request. + hdr := make(http.Header) + hdr.Set("Host", targetAddr) + if proxyURL.User != nil { + username := proxyURL.User.Username() + password, _ := proxyURL.User.Password() + cred := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + hdr.Set("Proxy-Authorization", "Basic "+cred) + } + connectReq := &http.Request{ + Method: "CONNECT", + URL: &url.URL{Opaque: targetAddr}, + Host: targetAddr, + Header: hdr, + } + if err := connectReq.Write(conn); err != nil { + conn.Close() + return nil, fmt.Errorf("write CONNECT: %w", err) + } + + // Read CONNECT response. Use a bufio.Reader, then check for buffered + // bytes to avoid data loss if the proxy sent anything beyond the header. + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, connectReq) + if err != nil { + conn.Close() + return nil, fmt.Errorf("read CONNECT response: %w", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + conn.Close() + return nil, fmt.Errorf("CONNECT to %s via %s: %s", targetAddr, proxyAddr, resp.Status) + } + + // If the bufio.Reader consumed extra bytes beyond the HTTP response, + // wrap the connection so those bytes are read first. + if br.Buffered() > 0 { + return &bufferedConn{Conn: conn, br: br}, nil + } + return conn, nil +} + +// bufferedConn wraps a net.Conn with a bufio.Reader to drain any bytes +// that were buffered during the HTTP CONNECT handshake. +type bufferedConn struct { + net.Conn + br *bufio.Reader +} + +func (c *bufferedConn) Read(p []byte) (int, error) { + if c.br.Buffered() > 0 { + return c.br.Read(p) + } + return c.Conn.Read(p) +} diff --git a/internal/auth/claude/utls_transport.go b/internal/auth/claude/utls_transport.go index 88b69c9bd9..cc7db94fdb 100644 --- a/internal/auth/claude/utls_transport.go +++ b/internal/auth/claude/utls_transport.go @@ -1,162 +1,113 @@ // Package claude provides authentication functionality for Anthropic's Claude API. -// This file implements a custom HTTP transport using utls to bypass TLS fingerprinting. +// This file implements a custom HTTP transport using utls to mimic Bun's BoringSSL +// TLS fingerprint, matching the real Claude Code CLI. package claude import ( + "context" + "fmt" + "net" "net/http" - "strings" "sync" + "time" tls "github.com/refraction-networking/utls" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" - "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. +// utlsRoundTripper implements http.RoundTripper using utls with Bun BoringSSL +// fingerprint to match the real Claude Code CLI's TLS characteristics. +// +// It uses HTTP/1.1 (Bun's ALPN only offers http/1.1) and delegates connection +// pooling to the standard http.Transport. +// +// Proxy support is handled by ProxyDialer (see proxy_dial.go), which establishes +// raw TCP connections through SOCKS5 or HTTP/HTTPS CONNECT proxies before this +// layer applies the utls TLS handshake. type utlsRoundTripper struct { - // mu protects the connections map and pending map - mu sync.Mutex - // connections caches HTTP/2 client connections per host - connections map[string]*http2.ClientConn - // pending tracks hosts that are currently being connected to (prevents race condition) - pending map[string]*sync.Cond - // dialer is used to create network connections, supporting proxies - dialer proxy.Dialer + transport *http.Transport + dialer *ProxyDialer // handles proxy tunneling for raw TCP connections } -// newUtlsRoundTripper creates a new utls-based round tripper with optional proxy support -func newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper { - var dialer proxy.Dialer = proxy.Direct - if cfg != nil { - proxyDialer, mode, errBuild := proxyutil.BuildDialer(cfg.ProxyURL) - if errBuild != nil { - log.Errorf("failed to configure proxy dialer for %q: %v", cfg.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, +// newUtlsRoundTripper creates a new utls-based round tripper with optional proxy support. +// The proxyURL parameter is the pre-resolved proxy URL string; an empty string means +// inherit proxy from environment variables (HTTPS_PROXY, HTTP_PROXY, ALL_PROXY). +func newUtlsRoundTripper(proxyURL string) *utlsRoundTripper { + rt := &utlsRoundTripper{dialer: NewProxyDialer(proxyURL)} + + rt.transport = &http.Transport{ + DialTLSContext: rt.dialTLS, + ForceAttemptHTTP2: false, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + ExpectContinueTimeout: 1 * time.Second, } + return rt } -// getOrCreateConnection gets an existing connection or creates a new one. -// It uses a per-host locking mechanism to prevent multiple goroutines from -// creating connections to the same host simultaneously. -func (t *utlsRoundTripper) getOrCreateConnection(host, addr string) (*http2.ClientConn, error) { - t.mu.Lock() - - // Check if connection exists and is usable - if h2Conn, ok := t.connections[host]; ok && h2Conn.CanTakeNewRequest() { - t.mu.Unlock() - return h2Conn, nil - } - - // Check if another goroutine is already creating a connection - if cond, ok := t.pending[host]; ok { - // Wait for the other goroutine to finish - cond.Wait() - // Check if connection is now available - if h2Conn, ok := t.connections[host]; ok && h2Conn.CanTakeNewRequest() { - t.mu.Unlock() - return h2Conn, nil - } - // Connection still not available, we'll create one +// dialTLS establishes a TLS connection using utls with the Bun BoringSSL spec. +// It delegates raw TCP dialing (including proxy tunneling) to ProxyDialer, +// then performs the utls handshake on the resulting connection. +func (t *utlsRoundTripper) dialTLS(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr } - // Mark this host as pending - cond := sync.NewCond(&t.mu) - t.pending[host] = cond - t.mu.Unlock() - - // Create connection outside the lock - h2Conn, err := t.createConnection(host, addr) - - t.mu.Lock() - defer t.mu.Unlock() - - // Remove pending marker and wake up waiting goroutines - delete(t.pending, host) - cond.Broadcast() - + // Step 1: Establish raw TCP connection (proxy tunneling handled internally). + conn, err := t.dialer.DialContext(ctx, network, addr) if err != nil { return nil, err } - // Store the new connection - t.connections[host] = h2Conn - return h2Conn, nil -} - -// createConnection creates a new HTTP/2 connection with Chrome TLS fingerprint. -// Chrome's TLS fingerprint is closer to Node.js/OpenSSL (which real Claude Code uses) -// than Firefox, reducing the mismatch between TLS layer and HTTP headers. -func (t *utlsRoundTripper) createConnection(host, addr string) (*http2.ClientConn, error) { - conn, err := t.dialer.Dial("tcp", addr) - if err != nil { - return nil, err + // Step 2: Propagate context deadline to TLS handshake to prevent indefinite hangs. + if deadline, ok := ctx.Deadline(); ok { + conn.SetDeadline(deadline) + defer conn.SetDeadline(time.Time{}) } + // Step 3: TLS handshake with Bun BoringSSL fingerprint. tlsConfig := &tls.Config{ServerName: host} - tlsConn := tls.UClient(conn, tlsConfig, tls.HelloChrome_Auto) - - if err := tlsConn.Handshake(); err != nil { + tlsConn := tls.UClient(conn, tlsConfig, tls.HelloCustom) + if err := tlsConn.ApplyPreset(BunBoringSSLSpec()); err != nil { conn.Close() - return nil, err + return nil, fmt.Errorf("apply Bun TLS spec: %w", err) } - - tr := &http2.Transport{} - h2Conn, err := tr.NewClientConn(tlsConn) - if err != nil { - tlsConn.Close() + if err := tlsConn.Handshake(); err != nil { + conn.Close() return nil, err } - return h2Conn, nil + return tlsConn, nil } -// RoundTrip implements http.RoundTripper +// RoundTrip implements http.RoundTripper. func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - host := req.URL.Host - addr := host - if !strings.Contains(addr, ":") { - addr += ":443" - } - - // Get hostname without port for TLS ServerName - hostname := req.URL.Hostname() - - h2Conn, err := t.getOrCreateConnection(hostname, addr) - if err != nil { - return nil, err - } - - resp, err := h2Conn.RoundTrip(req) - if err != nil { - // Connection failed, remove it from cache - 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 + return t.transport.RoundTrip(req) } -// NewAnthropicHttpClient creates an HTTP client that bypasses TLS fingerprinting -// for Anthropic domains by using utls with Chrome fingerprint. -// It accepts optional SDK configuration for proxy settings. -func NewAnthropicHttpClient(cfg *config.SDKConfig) *http.Client { - return &http.Client{ - Transport: newUtlsRoundTripper(cfg), +// anthropicClients caches *http.Client instances keyed by proxyURL string. +// Each unique proxyURL gets a single shared client whose http.Transport maintains +// its own idle connection pool — this avoids a full TLS handshake per request. +// The number of unique proxy URLs is typically very small (1-3), so entries are +// never evicted. +var anthropicClients sync.Map // map[string]*http.Client + +// NewAnthropicHttpClient returns a cached HTTP client that uses Bun BoringSSL TLS +// fingerprint for all connections, matching real Claude Code CLI behavior. +// +// Clients are cached per proxyURL so that the underlying http.Transport connection +// pool is reused across requests with the same proxy configuration. +// +// The proxyURL parameter is the pre-resolved proxy URL (e.g. from ResolveProxyURL). +// Pass an empty string to inherit proxy from environment variables. +func NewAnthropicHttpClient(proxyURL string) *http.Client { + if cached, ok := anthropicClients.Load(proxyURL); ok { + return cached.(*http.Client) + } + client := &http.Client{ + Transport: newUtlsRoundTripper(proxyURL), } + actual, _ := anthropicClients.LoadOrStore(proxyURL, client) + return actual.(*http.Client) } diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 82b12a2f80..c38661a3c7 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -87,7 +87,7 @@ func (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Aut if err := e.PrepareRequest(httpReq, auth); err != nil { return nil, err } - httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpClient := newClaudeHTTPClient(e.cfg, auth) return httpClient.Do(httpReq) } @@ -179,7 +179,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r AuthValue: authValue, }) - httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpClient := newClaudeHTTPClient(e.cfg, auth) httpResp, err := httpClient.Do(httpReq) if err != nil { recordAPIResponseError(ctx, e.cfg, err) @@ -342,7 +342,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A AuthValue: authValue, }) - httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpClient := newClaudeHTTPClient(e.cfg, auth) httpResp, err := httpClient.Do(httpReq) if err != nil { recordAPIResponseError(ctx, e.cfg, err) @@ -509,7 +509,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut AuthValue: authValue, }) - httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpClient := newClaudeHTTPClient(e.cfg, auth) resp, err := httpClient.Do(httpReq) if err != nil { recordAPIResponseError(ctx, e.cfg, err) @@ -578,7 +578,10 @@ func (e *ClaudeExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) ( if refreshToken == "" { return auth, nil } - svc := claudeauth.NewClaudeAuth(e.cfg) + // Use per-account proxy_url for token refresh, matching the priority in + // proxy_helpers.go: auth.ProxyURL > cfg.ProxyURL > env vars. + proxyURL := ResolveProxyURL(e.cfg, auth) + svc := claudeauth.NewClaudeAuth(e.cfg, proxyURL) td, err := svc.RefreshTokens(ctx, refreshToken) if err != nil { return nil, err diff --git a/internal/runtime/executor/proxy_helpers.go b/internal/runtime/executor/proxy_helpers.go index 5511497b9e..6498f7ef7a 100644 --- a/internal/runtime/executor/proxy_helpers.go +++ b/internal/runtime/executor/proxy_helpers.go @@ -6,17 +6,56 @@ import ( "strings" "time" + claudeauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude" "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" ) +// ResolveProxyURL returns the effective proxy URL following the standard priority: +// 1. auth.ProxyURL (per-account override) +// 2. cfg.ProxyURL (global config) +// 3. "" (empty — caller decides fallback behavior) +func ResolveProxyURL(cfg *config.Config, auth *cliproxyauth.Auth) string { + if auth != nil { + if u := strings.TrimSpace(auth.ProxyURL); u != "" { + return u + } + } + if cfg != nil { + if u := strings.TrimSpace(cfg.ProxyURL); u != "" { + return u + } + } + return "" +} + +// newClaudeHTTPClient returns an HTTP client for Anthropic API requests using +// utls with Bun BoringSSL TLS fingerprint. The client is cached per proxy URL, +// so the underlying http.Transport connection pool is reused across requests. +// +// Proxy priority: auth.ProxyURL > cfg.ProxyURL > env (HTTPS_PROXY etc.) > direct. +// +// NOTE: This function intentionally does NOT accept a context or honor the +// "cliproxy.roundtripper" context value. An injected RoundTripper would replace +// the entire TLS layer, breaking the Bun BoringSSL fingerprint that this PR +// unifies. SDK embedders requiring custom transport for Claude should configure +// proxy-url instead. For non-Claude providers, newProxyAwareHTTPClient continues +// to honor context RoundTrippers. +func newClaudeHTTPClient(cfg *config.Config, auth *cliproxyauth.Auth) *http.Client { + proxyURL := ResolveProxyURL(cfg, auth) + return claudeauth.NewAnthropicHttpClient(proxyURL) +} + // newProxyAwareHTTPClient creates an HTTP client with proper proxy configuration priority: // 1. Use auth.ProxyURL if configured (highest priority) // 2. Use cfg.ProxyURL if auth proxy is not configured // 3. Use RoundTripper from context if neither are configured // +// NOTE: This function uses standard Go TLS. For Anthropic/Claude API requests, +// use newClaudeHTTPClient() instead, which uses Bun BoringSSL TLS fingerprint. +// // Parameters: // - ctx: The context containing optional RoundTripper // - cfg: The application configuration @@ -31,16 +70,7 @@ func newProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *clip httpClient.Timeout = timeout } - // Priority 1: Use auth.ProxyURL if configured - var proxyURL string - if auth != nil { - proxyURL = strings.TrimSpace(auth.ProxyURL) - } - - // Priority 2: Use cfg.ProxyURL if auth proxy is not configured - if proxyURL == "" && cfg != nil { - proxyURL = strings.TrimSpace(cfg.ProxyURL) - } + proxyURL := ResolveProxyURL(cfg, auth) // If we have a proxy URL configured, set up the transport if proxyURL != "" { diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index b29e04db8c..72717c9afe 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -2638,8 +2638,12 @@ func (m *Manager) refreshAuth(ctx context.Context, id string) { log.Debugf("refresh canceled for %s, %s", auth.Provider, auth.ID) return } - log.Debugf("refreshed %s, %s, %v", auth.Provider, auth.ID, err) now := time.Now() + if err != nil { + log.Warnf("auth refresh failed for %s/%s: %v", auth.Provider, auth.ID, err) + } else { + log.Debugf("auth refresh ok for %s/%s", auth.Provider, auth.ID) + } if err != nil { m.mu.Lock() if current := m.auths[id]; current != nil {