Skip to content
19 changes: 13 additions & 6 deletions internal/auth/claude/anthropic_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

Expand Down
131 changes: 131 additions & 0 deletions internal/auth/claude/bun_tls_spec.go
Original file line number Diff line number Diff line change
@@ -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},
},
}
}
164 changes: 50 additions & 114 deletions internal/auth/claude/utls_transport.go
Original file line number Diff line number Diff line change
@@ -1,162 +1,98 @@
// 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"

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.
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 proxy.Dialer
}

// newUtlsRoundTripper creates a new utls-based round tripper with optional proxy support
func newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper {
// 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
// no proxy (direct connection).
func newUtlsRoundTripper(proxyURL string) *utlsRoundTripper {
var dialer proxy.Dialer = proxy.Direct
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The default dialer is set to proxy.Direct, which explicitly bypasses any proxy settings from environment variables like HTTPS_PROXY. The PR description states the intended proxy priority is auth.ProxyURL > cfg.ProxyURL > env. To align with this and respect environment variables when no other proxy is configured, the default dialer should be proxy.FromEnvironment(). This function correctly falls back to a direct connection if no proxy environment variables are set.

Suggested change
var dialer proxy.Dialer = proxy.Direct
var dialer proxy.Dialer = proxy.FromEnvironment()

if cfg != nil {
proxyDialer, mode, errBuild := proxyutil.BuildDialer(cfg.ProxyURL)
if proxyURL != "" {
proxyDialer, mode, errBuild := proxyutil.BuildDialer(proxyURL)
if errBuild != nil {
log.Errorf("failed to configure proxy dialer for %q: %v", cfg.ProxyURL, errBuild)
log.Errorf("failed to configure proxy dialer for %q: %v", proxyURL, errBuild)
} else if mode != proxyutil.ModeInherit && proxyDialer != nil {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep HTTP/HTTPS proxy-url working for Claude traffic

If proxy-url is set to http://... or https://..., this path now calls proxyutil.BuildDialer(), but sdk/proxyutil/proxy.go:119-135 delegates to golang.org/x/net/proxy.FromURL, whose built-in schemes are only SOCKS (go.googlesource.com/net/proxy/proxy.go#73-100). In practice the error branch here will fire for the documented HTTP/HTTPS proxy modes from config.example.yaml:65-67, and newClaudeHTTPClient()/claude_executor.go now routes all Claude requests through this transport, so message/count-token calls will ignore the configured corporate proxy and go env/direct instead.

Useful? React with 👍 / 👎.

dialer = proxyDialer
}
}

return &utlsRoundTripper{
connections: make(map[string]*http2.ClientConn),
pending: make(map[string]*sync.Cond),
dialer: dialer,
rt := &utlsRoundTripper{dialer: dialer}
rt.transport = &http.Transport{
// Inject our custom TLS dial function so every connection uses
// the Bun BoringSSL fingerprint via utls.
DialTLSContext: rt.dialTLS,
// Force HTTP/1.1 — do not attempt h2 upgrade.
ForceAttemptHTTP2: false,
}
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
}

// 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()

// dialTLS establishes a TLS connection using utls with the Bun BoringSSL spec.
// This is called by http.Transport for every new TLS connection.
func (t *utlsRoundTripper) dialTLS(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
// addr might not have a port; use as-is for ServerName
host = addr
}

Comment on lines +52 to 56

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve port-specific NO_PROXY matches for custom HTTPS ports

This strips the destination port before proxy resolution. resolveProxy then builds https://<host> and feeds that to http.ProxyFromEnvironment, so the NO_PROXY check is evaluated as <host>:443 regardless of the real upstream port. For custom Claude base-urls on alternate HTTPS ports (for example https://gateway.example.com:8443), a bypass like NO_PROXY=gateway.example.com:8443 will stop matching and the request will be sent through the proxy unexpectedly; the previous stdlib path used the real request URL and kept that port-sensitive behavior.

Useful? React with 👍 / 👎.

// 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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor request context during TLS dialing

dialTLS receives a context but ignores it and calls t.dialer.Dial directly. Because this transport is built from a bare http.Transport, there is no default dial timeout to compensate, so Claude requests no longer respect cancellation/deadlines while the TCP/TLS connection is being established. A blackholed Anthropic endpoint or hung proxy can therefore block a worker indefinitely instead of timing out and triggering the existing retry/failover paths.

Useful? React with 👍 / 👎.

}

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 by delegating to the underlying
// http.Transport which uses our custom TLS dial function.
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 {
// NewAnthropicHttpClient creates an HTTP client that uses Bun BoringSSL TLS
// fingerprint for all connections, matching real Claude Code CLI behavior.
//
// The proxyURL parameter is the pre-resolved proxy URL (e.g. from ResolveProxyURL).
// Pass an empty string for direct connections.
func NewAnthropicHttpClient(proxyURL string) *http.Client {
return &http.Client{
Transport: newUtlsRoundTripper(cfg),
Transport: newUtlsRoundTripper(proxyURL),
}
}

Loading
Loading