mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-07 00:24:17 +08:00
Align the remaining measured OAuth wire profiles, including the ordered connection writer in internal/httpwire that reproduces the observed header sequence, and the refresh/profile response shapes in internal/auth/claude. Replay the measured Fast path and keep diagnostic continuity across cloaked and native requests. Preserve the native direct token-counting shape so a caller that reaches count_tokens itself is not reshaped into the cloaked form. Scope cloak dates to the credential's timezone rather than the host's, so currentDate matches what the real client would have sent for that account.
378 lines
11 KiB
Go
378 lines
11 KiB
Go
package helps
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
tls "github.com/refraction-networking/utls"
|
|
"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.
|
|
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", proxyutil.Redact(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) {
|
|
hostname := req.URL.Hostname()
|
|
port := req.URL.Port()
|
|
if port == "" {
|
|
port = "443"
|
|
}
|
|
addr := net.JoinHostPort(hostname, port)
|
|
|
|
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
|
|
}
|
|
|
|
// 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},
|
|
},
|
|
}
|
|
}
|
|
|
|
var claudeCodeRoundTripperCache sync.Map
|
|
|
|
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 {
|
|
if cached, ok := claudeCodeRoundTripperCache.Load(proxyURL); ok {
|
|
return cached.(http.RoundTripper)
|
|
}
|
|
created := newClaudeCodeRoundTripper(proxyURL)
|
|
actual, loaded := claudeCodeRoundTripperCache.LoadOrStore(proxyURL, created)
|
|
if loaded {
|
|
if transport, ok := created.(*http.Transport); ok {
|
|
transport.CloseIdleConnections()
|
|
}
|
|
return actual.(http.RoundTripper)
|
|
}
|
|
return created
|
|
}
|
|
|
|
func newClaudeCodeRoundTripper(proxyURL string) http.RoundTripper {
|
|
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, &tls.Config{ServerName: host}, 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 req.URL.Scheme == "https" {
|
|
switch strings.ToLower(req.URL.Hostname()) {
|
|
case "api.anthropic.com":
|
|
return f.anthropic.RoundTrip(req)
|
|
case "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
|
|
}
|