407 lines
13 KiB
Go
407 lines
13 KiB
Go
package helps
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
tls "github.com/refraction-networking/utls"
|
|
internalcache "github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/httpwire"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.org/x/net/http2"
|
|
"golang.org/x/net/proxy"
|
|
)
|
|
|
|
// utlsRoundTripper implements http.RoundTripper using a Chrome fingerprint for
|
|
// providers that require a browser-like TLS and HTTP/2 transport. Each request
|
|
// gets a dedicated connection that is closed with the response body.
|
|
type utlsRoundTripper struct {
|
|
dialer proxy.Dialer
|
|
}
|
|
|
|
type closeConnectionBody struct {
|
|
io.ReadCloser
|
|
closeConnection func() error
|
|
once sync.Once
|
|
err error
|
|
}
|
|
|
|
func (b *closeConnectionBody) Close() error {
|
|
if b == nil {
|
|
return nil
|
|
}
|
|
b.once.Do(func() {
|
|
var errConnection error
|
|
if b.closeConnection != nil {
|
|
errConnection = b.closeConnection()
|
|
}
|
|
var errBody error
|
|
if b.ReadCloser != nil {
|
|
errBody = b.ReadCloser.Close()
|
|
}
|
|
b.err = errors.Join(errBody, errConnection)
|
|
})
|
|
return b.err
|
|
}
|
|
|
|
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", proxyutil.Redact(proxyURL), errBuild)
|
|
} else if mode != proxyutil.ModeInherit && proxyDialer != nil {
|
|
dialer = proxyDialer
|
|
}
|
|
}
|
|
return &utlsRoundTripper{dialer: dialer}
|
|
}
|
|
|
|
func (t *utlsRoundTripper) createConnection(ctx context.Context, host, addr string) (*http2.ClientConn, error) {
|
|
contextDialer, ok := t.dialer.(proxy.ContextDialer)
|
|
if !ok {
|
|
return nil, fmt.Errorf("utls: dialer does not support context cancellation")
|
|
}
|
|
conn, errDial := contextDialer.DialContext(ctx, "tcp", addr)
|
|
if errDial != nil {
|
|
return nil, fmt.Errorf("utls: dial upstream: %w", errDial)
|
|
}
|
|
|
|
tlsConfig := &tls.Config{ServerName: host}
|
|
tlsConn := tls.UClient(conn, tlsConfig, tls.HelloChrome_Auto)
|
|
|
|
if errHandshake := tlsConn.HandshakeContext(ctx); errHandshake != nil {
|
|
if errors.Is(errHandshake, context.Canceled) || errors.Is(errHandshake, context.DeadlineExceeded) {
|
|
return nil, fmt.Errorf("utls: TLS handshake: %w", errHandshake)
|
|
}
|
|
if errClose := conn.Close(); errClose != nil {
|
|
return nil, fmt.Errorf("utls: TLS handshake: %w; close connection: %v", errHandshake, errClose)
|
|
}
|
|
return nil, fmt.Errorf("utls: TLS handshake: %w", errHandshake)
|
|
}
|
|
|
|
tr := &http2.Transport{}
|
|
h2Conn, errClientConn := tr.NewClientConn(tlsConn)
|
|
if errClientConn != nil {
|
|
if errClose := tlsConn.Close(); errClose != nil {
|
|
return nil, fmt.Errorf("utls: initialize HTTP/2 connection: %w; close TLS connection: %v", errClientConn, errClose)
|
|
}
|
|
return nil, fmt.Errorf("utls: initialize HTTP/2 connection: %w", errClientConn)
|
|
}
|
|
|
|
return h2Conn, nil
|
|
}
|
|
|
|
func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
hostname := req.URL.Hostname()
|
|
port := req.URL.Port()
|
|
if port == "" {
|
|
port = "443"
|
|
}
|
|
addr := net.JoinHostPort(hostname, port)
|
|
|
|
h2Conn, err := t.createConnection(req.Context(), hostname, addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := h2Conn.RoundTrip(req)
|
|
if err != nil {
|
|
if errClose := h2Conn.Close(); errClose != nil {
|
|
log.Debugf("utls: close connection after round trip failure: %v", errClose)
|
|
}
|
|
return nil, err
|
|
}
|
|
if resp == nil {
|
|
if errClose := h2Conn.Close(); errClose != nil {
|
|
log.Debugf("utls: close connection after empty response: %v", errClose)
|
|
}
|
|
return nil, fmt.Errorf("utls: upstream returned an empty response")
|
|
}
|
|
if resp.Body == nil {
|
|
resp.Body = http.NoBody
|
|
}
|
|
resp.Body = &closeConnectionBody{
|
|
ReadCloser: resp.Body,
|
|
closeConnection: h2Conn.Close,
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// claudeCodeSessionCacheCapacity bounds the per-transport TLS session cache for
|
|
// the Anthropic inference plane.
|
|
const claudeCodeSessionCacheCapacity = 32
|
|
|
|
// newClaudeCodeTLSConfig builds the uTLS config for one inference-plane dial.
|
|
//
|
|
// OmitEmptyPsk keeps the pre_shared_key extension silent until a session is
|
|
// cached, so an unresumed ClientHello stays byte-identical to the captured
|
|
// native handshake. PreferSkipResumptionOnNilExtension turns uTLS's HelloCustom
|
|
// "resume without the matching extension" panic into a skipped resumption.
|
|
func newClaudeCodeTLSConfig(host string, sessionCache tls.ClientSessionCache) *tls.Config {
|
|
return &tls.Config{
|
|
ServerName: host,
|
|
ClientSessionCache: sessionCache,
|
|
OmitEmptyPsk: true,
|
|
PreferSkipResumptionOnNilExtension: true,
|
|
}
|
|
}
|
|
|
|
// claudeCodeTLSClientHelloSpec reproduces the deterministic Node/OpenSSL
|
|
// ClientHello emitted by Claude Code 2.1.220 on macOS arm64. Keep this spec in
|
|
// sync with a fresh native capture whenever the advertised Claude Code version
|
|
// changes.
|
|
func claudeCodeTLSClientHelloSpec() *tls.ClientHelloSpec {
|
|
return &tls.ClientHelloSpec{
|
|
CipherSuites: []uint16{
|
|
tls.TLS_AES_128_GCM_SHA256,
|
|
tls.TLS_AES_256_GCM_SHA384,
|
|
tls.TLS_CHACHA20_POLY1305_SHA256,
|
|
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
|
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
|
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
|
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
|
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
|
|
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
|
|
tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
|
|
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
|
|
tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
|
|
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
|
|
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
|
|
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
|
|
tls.TLS_RSA_WITH_AES_128_CBC_SHA,
|
|
tls.TLS_RSA_WITH_AES_256_CBC_SHA,
|
|
},
|
|
CompressionMethods: []uint8{0},
|
|
Extensions: []tls.TLSExtension{
|
|
&tls.SNIExtension{},
|
|
&tls.ExtendedMasterSecretExtension{},
|
|
&tls.RenegotiationInfoExtension{Renegotiation: tls.RenegotiateOnceAsClient},
|
|
&tls.SupportedCurvesExtension{Curves: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384}},
|
|
&tls.SupportedPointsExtension{SupportedPoints: []byte{0}},
|
|
&tls.SessionTicketExtension{},
|
|
&tls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}},
|
|
&tls.StatusRequestExtension{},
|
|
&tls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []tls.SignatureScheme{
|
|
tls.ECDSAWithP256AndSHA256,
|
|
tls.PSSWithSHA256,
|
|
tls.PKCS1WithSHA256,
|
|
tls.ECDSAWithP384AndSHA384,
|
|
tls.PSSWithSHA384,
|
|
tls.PKCS1WithSHA384,
|
|
tls.PSSWithSHA512,
|
|
tls.PKCS1WithSHA512,
|
|
tls.PKCS1WithSHA1,
|
|
}},
|
|
&tls.SCTExtension{},
|
|
&tls.KeyShareExtension{KeyShares: []tls.KeyShare{{Group: tls.X25519}}},
|
|
&tls.PSKKeyExchangeModesExtension{Modes: []uint8{tls.PskModeDHE}},
|
|
&tls.SupportedVersionsExtension{Versions: []uint16{tls.VersionTLS13, tls.VersionTLS12}},
|
|
&tls.UtlsPaddingExtension{GetPaddingLen: tls.BoringPaddingStyle},
|
|
// pre_shared_key MUST be the final extension (RFC 8446 4.2.11), after
|
|
// padding. It contributes zero bytes until a cached session exists.
|
|
&tls.UtlsPreSharedKeyExtension{},
|
|
},
|
|
}
|
|
}
|
|
|
|
const claudeCodeRoundTripperCacheCapacity = 64
|
|
|
|
var claudeCodeRoundTripperCache = internalcache.NewBoundedLRU[string, http.RoundTripper](
|
|
claudeCodeRoundTripperCacheCapacity,
|
|
func(_ string, roundTripper http.RoundTripper) {
|
|
if transport, ok := roundTripper.(interface{ CloseIdleConnections() }); ok {
|
|
transport.CloseIdleConnections()
|
|
}
|
|
},
|
|
)
|
|
|
|
var claudeCodeMessagesHeaderOrder = []string{
|
|
"Accept",
|
|
"Authorization",
|
|
"Content-Type",
|
|
"User-Agent",
|
|
"X-Claude-Code-Session-Id",
|
|
"X-Stainless-Arch",
|
|
"X-Stainless-Lang",
|
|
"X-Stainless-OS",
|
|
"X-Stainless-Package-Version",
|
|
"X-Stainless-Retry-Count",
|
|
"X-Stainless-Runtime",
|
|
"X-Stainless-Runtime-Version",
|
|
"X-Stainless-Timeout",
|
|
"anthropic-beta",
|
|
"anthropic-dangerous-direct-browser-access",
|
|
"anthropic-version",
|
|
"x-app",
|
|
"x-client-request-id",
|
|
"Connection",
|
|
"Host",
|
|
"Accept-Encoding",
|
|
"Content-Length",
|
|
}
|
|
|
|
var claudeCodeCountTokensHeaderOrder = []string{
|
|
"Accept",
|
|
"Authorization",
|
|
"Content-Type",
|
|
"User-Agent",
|
|
"X-Claude-Code-Session-Id",
|
|
"X-Stainless-Arch",
|
|
"X-Stainless-Lang",
|
|
"X-Stainless-OS",
|
|
"X-Stainless-Package-Version",
|
|
"X-Stainless-Retry-Count",
|
|
"X-Stainless-Runtime",
|
|
"X-Stainless-Runtime-Version",
|
|
"anthropic-beta",
|
|
"anthropic-dangerous-direct-browser-access",
|
|
"anthropic-version",
|
|
"x-app",
|
|
"x-client-request-id",
|
|
"Connection",
|
|
"Host",
|
|
"Accept-Encoding",
|
|
"Content-Length",
|
|
}
|
|
|
|
func claudeCodeRequestHeaderOrder(_, requestTarget string) []string {
|
|
if strings.HasPrefix(requestTarget, "/v1/messages/count_tokens") {
|
|
return claudeCodeCountTokensHeaderOrder
|
|
}
|
|
return claudeCodeMessagesHeaderOrder
|
|
}
|
|
|
|
func cachedClaudeCodeRoundTripper(proxyURL string) http.RoundTripper {
|
|
return claudeCodeRoundTripperCache.GetOrAdd(proxyURL, func() http.RoundTripper {
|
|
return newClaudeCodeRoundTripper(proxyURL)
|
|
})
|
|
}
|
|
|
|
func newClaudeCodeRoundTripper(proxyURL string) http.RoundTripper {
|
|
// The cache is scoped to this round tripper, which is already keyed by proxy,
|
|
// so resumption never crosses proxy boundaries.
|
|
sessionCache := tls.NewLRUClientSessionCache(claudeCodeSessionCacheCapacity)
|
|
var dialer proxy.Dialer = proxy.Direct
|
|
if proxyURL != "" {
|
|
proxyDialer, mode, errBuild := proxyutil.BuildDialer(proxyURL)
|
|
if errBuild != nil {
|
|
log.Errorf("claude tls: failed to configure proxy dialer for %q: %v", proxyutil.Redact(proxyURL), errBuild)
|
|
} else if mode != proxyutil.ModeInherit && proxyDialer != nil {
|
|
dialer = proxyDialer
|
|
}
|
|
}
|
|
|
|
transport := &http.Transport{
|
|
ForceAttemptHTTP2: false,
|
|
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
var (
|
|
conn net.Conn
|
|
err error
|
|
)
|
|
if contextDialer, ok := dialer.(proxy.ContextDialer); ok {
|
|
conn, err = contextDialer.DialContext(ctx, network, addr)
|
|
} else {
|
|
conn, err = dialer.Dial(network, addr)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("claude tls: dial upstream: %w", err)
|
|
}
|
|
|
|
host, _, errSplit := net.SplitHostPort(addr)
|
|
if errSplit != nil {
|
|
if errClose := conn.Close(); errClose != nil {
|
|
log.Debugf("claude tls: close failed connection: %v", errClose)
|
|
}
|
|
return nil, fmt.Errorf("claude tls: split upstream address: %w", errSplit)
|
|
}
|
|
tlsConn := tls.UClient(conn, newClaudeCodeTLSConfig(host, sessionCache), tls.HelloCustom)
|
|
if errPreset := tlsConn.ApplyPreset(claudeCodeTLSClientHelloSpec()); errPreset != nil {
|
|
if errClose := tlsConn.Close(); errClose != nil {
|
|
log.Debugf("claude tls: close connection after preset failure: %v", errClose)
|
|
}
|
|
return nil, fmt.Errorf("claude tls: apply Claude Code ClientHello: %w", errPreset)
|
|
}
|
|
if errHandshake := tlsConn.HandshakeContext(ctx); errHandshake != nil {
|
|
if errClose := tlsConn.Close(); errClose != nil {
|
|
log.Debugf("claude tls: close connection after handshake failure: %v", errClose)
|
|
}
|
|
return nil, fmt.Errorf("claude tls: handshake upstream: %w", errHandshake)
|
|
}
|
|
return httpwire.NewOrderedRequestConn(tlsConn, claudeCodeRequestHeaderOrder), nil
|
|
},
|
|
}
|
|
return transport
|
|
}
|
|
|
|
// fallbackRoundTripper uses provider-specific TLS fingerprints for protected
|
|
// HTTPS hosts and falls back to the standard transport for all other requests.
|
|
type fallbackRoundTripper struct {
|
|
anthropic http.RoundTripper
|
|
chrome http.RoundTripper
|
|
fallback http.RoundTripper
|
|
}
|
|
|
|
func (f *fallbackRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
if IsAnthropicUpstreamURL(req.URL) {
|
|
return f.anthropic.RoundTrip(req)
|
|
}
|
|
if req.URL.Scheme == "https" && strings.EqualFold(req.URL.Hostname(), "chatgpt.com") {
|
|
return f.chrome.RoundTrip(req)
|
|
}
|
|
return f.fallback.RoundTrip(req)
|
|
}
|
|
|
|
// NewUtlsHTTPClient creates an HTTP client using provider-specific TLS
|
|
// fingerprints for protected hosts. It uses Claude Code's Node/OpenSSL profile
|
|
// for Anthropic and a Chrome profile for ChatGPT, with a standard-transport
|
|
// fallback for other hosts.
|
|
func NewUtlsHTTPClient(ctx context.Context, 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)
|
|
}
|
|
|
|
var ctxRoundTripper http.RoundTripper
|
|
if ctx != nil {
|
|
ctxRoundTripper, _ = ctx.Value("cliproxy.roundtripper").(http.RoundTripper)
|
|
}
|
|
|
|
var chromeRT http.RoundTripper = newUtlsRoundTripper(proxyURL)
|
|
var anthropicRT http.RoundTripper = cachedClaudeCodeRoundTripper(proxyURL)
|
|
var standardTransport http.RoundTripper = http.DefaultTransport
|
|
if proxyURL != "" {
|
|
if transport := buildProxyTransport(proxyURL); transport != nil {
|
|
standardTransport = transport
|
|
}
|
|
} else if ctxRoundTripper != nil {
|
|
chromeRT = ctxRoundTripper
|
|
anthropicRT = ctxRoundTripper
|
|
standardTransport = ctxRoundTripper
|
|
}
|
|
|
|
client := &http.Client{
|
|
Transport: &fallbackRoundTripper{
|
|
anthropic: anthropicRT,
|
|
chrome: chromeRT,
|
|
fallback: standardTransport,
|
|
},
|
|
}
|
|
if timeout > 0 {
|
|
client.Timeout = timeout
|
|
}
|
|
return client
|
|
}
|