254 lines
9.1 KiB
Go
254 lines
9.1 KiB
Go
package claude
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"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/httpwire"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.org/x/net/proxy"
|
|
)
|
|
|
|
type claudeRefreshHandshakeTimeoutContextKey struct{}
|
|
|
|
var claudeOAuthRefreshHeaderOrder = []string{
|
|
"Accept",
|
|
"Content-Type",
|
|
"User-Agent",
|
|
"Content-Length",
|
|
"Accept-Encoding",
|
|
"Host",
|
|
"Connection",
|
|
}
|
|
|
|
// claudeOAuthInspectHeaderOrder is the order the native client emits for the
|
|
// authenticated Axios GET lookups on the OAuth control plane, covering both the
|
|
// account profile and the claude_cli roles companion request.
|
|
var claudeOAuthInspectHeaderOrder = []string{
|
|
"Accept",
|
|
"Content-Type",
|
|
"Authorization",
|
|
"Cache-Control",
|
|
"User-Agent",
|
|
"Accept-Encoding",
|
|
"Host",
|
|
"Connection",
|
|
}
|
|
|
|
// claudeOAuthInspectTargets are the authenticated control-plane GET paths that
|
|
// use claudeOAuthInspectHeaderOrder.
|
|
var claudeOAuthInspectTargets = []string{
|
|
"/api/oauth/profile",
|
|
"/api/oauth/claude_cli/roles",
|
|
}
|
|
|
|
func claudeOAuthRequestHeaderOrder(method, requestTarget string) []string {
|
|
if method == http.MethodGet {
|
|
for _, target := range claudeOAuthInspectTargets {
|
|
if strings.HasPrefix(requestTarget, target) {
|
|
return claudeOAuthInspectHeaderOrder
|
|
}
|
|
}
|
|
}
|
|
return claudeOAuthRefreshHeaderOrder
|
|
}
|
|
|
|
// claudeOAuthSessionCacheCapacity bounds one proxy's TLS session cache. The
|
|
// OAuth control plane only talks to platform.claude.com and api.anthropic.com,
|
|
// so a small cache covers every reachable server.
|
|
const (
|
|
claudeOAuthSessionCacheCapacity = 8
|
|
claudeOAuthProxySessionCacheCapacity = 64
|
|
)
|
|
|
|
// claudeOAuthSessionCaches keys one session cache per effective proxy URL.
|
|
//
|
|
// ClaudeAuth is constructed per operation (every refresh and every executor
|
|
// profile check builds a new one), so a cache owned by the round tripper would
|
|
// always start empty and never resume. Keying on the proxy instead matches the
|
|
// inference plane, where the whole round tripper is cached per proxy, and keeps
|
|
// resumption from crossing proxy boundaries. TLS sessions are scoped to a
|
|
// server rather than a credential, and connections are already pooled per proxy
|
|
// on the inference plane, so this adds no new cross-credential linkage.
|
|
|
|
var claudeOAuthSessionCaches = internalcache.NewBoundedLRU[string, tls.ClientSessionCache](
|
|
claudeOAuthProxySessionCacheCapacity,
|
|
nil,
|
|
)
|
|
|
|
func claudeOAuthSessionCache(proxyURL string) tls.ClientSessionCache {
|
|
return claudeOAuthSessionCaches.GetOrAdd(proxyURL, func() tls.ClientSessionCache {
|
|
return tls.NewLRUClientSessionCache(claudeOAuthSessionCacheCapacity)
|
|
})
|
|
}
|
|
|
|
// newClaudeOAuthTLSConfig builds the uTLS config for one control-plane dial.
|
|
//
|
|
// OmitEmptyPsk keeps the pre_shared_key extension silent until a session is
|
|
// actually cached, so the first ClientHello is byte-identical to the captured
|
|
// native handshake. PreferSkipResumptionOnNilExtension is defense in depth: for
|
|
// HelloCustom specs uTLS panics when it wants to resume but the spec lacks the
|
|
// matching extension, and this degrades that into a skipped resumption.
|
|
func newClaudeOAuthTLSConfig(host string, sessionCache tls.ClientSessionCache) *tls.Config {
|
|
return &tls.Config{
|
|
ServerName: host,
|
|
ClientSessionCache: sessionCache,
|
|
OmitEmptyPsk: true,
|
|
PreferSkipResumptionOnNilExtension: true,
|
|
}
|
|
}
|
|
|
|
// claudeOAuthTLSClientHelloSpec reproduces the compact Node/OpenSSL profile
|
|
// Claude Code 2.1.220 uses for Axios OAuth control-plane requests. Unlike the
|
|
// inference profile, it advertises no ALPN extension and therefore uses
|
|
// HTTP/1.1 without negotiating a protocol.
|
|
func claudeOAuthTLSClientHelloSpec() *tls.ClientHelloSpec {
|
|
return &tls.ClientHelloSpec{
|
|
TLSVersMin: tls.VersionTLS12,
|
|
TLSVersMax: tls.VersionTLS13,
|
|
CompressionMethods: []uint8{0},
|
|
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,
|
|
},
|
|
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.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []tls.SignatureScheme{
|
|
tls.ECDSAWithP256AndSHA256,
|
|
tls.PSSWithSHA256,
|
|
tls.PKCS1WithSHA256,
|
|
tls.ECDSAWithP384AndSHA384,
|
|
tls.PSSWithSHA384,
|
|
tls.PKCS1WithSHA384,
|
|
tls.PSSWithSHA512,
|
|
tls.PKCS1WithSHA512,
|
|
tls.PKCS1WithSHA1,
|
|
}},
|
|
&tls.KeyShareExtension{KeyShares: []tls.KeyShare{{Group: tls.X25519}}},
|
|
&tls.PSKKeyExchangeModesExtension{Modes: []uint8{tls.PskModeDHE}},
|
|
&tls.SupportedVersionsExtension{Versions: []uint16{tls.VersionTLS13, tls.VersionTLS12}},
|
|
// pre_shared_key MUST be the final extension (RFC 8446 4.2.11). It
|
|
// contributes zero bytes until a cached session exists.
|
|
&tls.UtlsPreSharedKeyExtension{},
|
|
},
|
|
}
|
|
}
|
|
|
|
// utlsRoundTripper uses Claude Code's OAuth control-plane TLS and HTTP/1.1
|
|
// profile while retaining net/http proxy, cancellation, response parsing and
|
|
// connection lifecycle semantics.
|
|
type utlsRoundTripper struct {
|
|
dialer proxy.Dialer
|
|
// sessionCache is shared by every transport built for the same proxy, so
|
|
// short-lived ClaudeAuth instances can still resume, while resumption never
|
|
// crosses proxy boundaries.
|
|
sessionCache tls.ClientSessionCache
|
|
transport *http.Transport
|
|
}
|
|
|
|
func newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper {
|
|
var dialer proxy.Dialer = proxy.Direct
|
|
var proxyURL string
|
|
if cfg != nil {
|
|
proxyURL = cfg.ProxyURL
|
|
proxyDialer, mode, errBuild := proxyutil.BuildDialer(cfg.ProxyURL)
|
|
if errBuild != nil {
|
|
log.Errorf("failed to configure proxy dialer for %q: %v", proxyutil.Redact(cfg.ProxyURL), errBuild)
|
|
} else if mode != proxyutil.ModeInherit && proxyDialer != nil {
|
|
dialer = proxyDialer
|
|
}
|
|
}
|
|
|
|
roundTripper := &utlsRoundTripper{
|
|
dialer: dialer,
|
|
sessionCache: claudeOAuthSessionCache(proxyURL),
|
|
}
|
|
roundTripper.transport = &http.Transport{
|
|
ForceAttemptHTTP2: false,
|
|
DialTLSContext: roundTripper.dialTLSContext,
|
|
}
|
|
return roundTripper
|
|
}
|
|
|
|
func (t *utlsRoundTripper) dialTLSContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
var (
|
|
conn net.Conn
|
|
err error
|
|
)
|
|
if contextDialer, ok := t.dialer.(proxy.ContextDialer); ok {
|
|
conn, err = contextDialer.DialContext(ctx, network, addr)
|
|
} else {
|
|
conn, err = t.dialer.Dial(network, addr)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("claude oauth tls: dial upstream: %w", err)
|
|
}
|
|
|
|
host, _, errSplit := net.SplitHostPort(addr)
|
|
if errSplit != nil {
|
|
if errClose := conn.Close(); errClose != nil {
|
|
log.Debugf("claude oauth tls: close failed connection: %v", errClose)
|
|
}
|
|
return nil, fmt.Errorf("claude oauth tls: split upstream address: %w", errSplit)
|
|
}
|
|
tlsConn := tls.UClient(conn, newClaudeOAuthTLSConfig(host, t.sessionCache), tls.HelloCustom)
|
|
if errPreset := tlsConn.ApplyPreset(claudeOAuthTLSClientHelloSpec()); errPreset != nil {
|
|
if errClose := tlsConn.Close(); errClose != nil {
|
|
log.Debugf("claude oauth tls: close connection after preset failure: %v", errClose)
|
|
}
|
|
return nil, fmt.Errorf("claude oauth tls: apply ClientHello: %w", errPreset)
|
|
}
|
|
handshakeCtx := ctx
|
|
if handshakeTimeout, _ := ctx.Value(claudeRefreshHandshakeTimeoutContextKey{}).(time.Duration); handshakeTimeout > 0 {
|
|
var cancelHandshake context.CancelFunc
|
|
handshakeCtx, cancelHandshake = context.WithTimeout(ctx, handshakeTimeout)
|
|
defer cancelHandshake()
|
|
}
|
|
if errHandshake := tlsConn.HandshakeContext(handshakeCtx); errHandshake != nil {
|
|
if errClose := tlsConn.Close(); errClose != nil {
|
|
log.Debugf("claude oauth tls: close connection after handshake failure: %v", errClose)
|
|
}
|
|
return nil, fmt.Errorf("claude oauth tls: handshake upstream: %w", errHandshake)
|
|
}
|
|
return httpwire.NewOrderedRequestConn(tlsConn, claudeOAuthRequestHeaderOrder), nil
|
|
}
|
|
|
|
func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return t.transport.RoundTrip(req)
|
|
}
|
|
|
|
func (t *utlsRoundTripper) CloseIdleConnections() {
|
|
t.transport.CloseIdleConnections()
|
|
}
|
|
|
|
func NewAnthropicHttpClient(cfg *config.SDKConfig) *http.Client {
|
|
return &http.Client{Transport: newUtlsRoundTripper(cfg)}
|
|
}
|