Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
254
backend/internal/auth/claude/utls_transport.go
Normal file
254
backend/internal/auth/claude/utls_transport.go
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
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)}
|
||||
}
|
||||
Loading…
Reference in a new issue