vibe-proxy/backend/internal/auth/claude/utls_transport.go
2026-08-24 00:10:41 +02:00

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)}
}