From 9abda1bc5917dfb9a4f2eba5fed509fb2e416ac9 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 18 Jan 2026 20:06:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(tls):=20=E6=96=B0=E5=A2=9E=20TLS=20?= =?UTF-8?q?=E6=8C=87=E7=BA=B9=E6=A8=A1=E6=8B=9F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/config/config.go | 29 + backend/internal/handler/dto/mappers.go | 5 + backend/internal/handler/dto/types.go | 4 + backend/internal/pkg/tlsfingerprint/dialer.go | 564 ++++++++++++++++++ .../pkg/tlsfingerprint/dialer_test.go | 307 ++++++++++ .../internal/pkg/tlsfingerprint/registry.go | 170 ++++++ .../pkg/tlsfingerprint/registry_test.go | 243 ++++++++ backend/internal/repository/http_upstream.go | 232 +++++++ backend/internal/service/account.go | 19 + .../internal/service/account_test_service.go | 6 +- backend/internal/service/gateway_service.go | 70 ++- .../internal/service/http_upstream_port.go | 25 + deploy/README.md | 55 ++ deploy/config.example.yaml | 13 + .../components/account/CreateAccountModal.vue | 39 ++ .../components/account/EditAccountModal.vue | 41 ++ frontend/src/i18n/locales/en.ts | 4 + frontend/src/i18n/locales/zh.ts | 4 + frontend/src/types/index.ts | 3 + frontend/tsconfig.json | 1 + 20 files changed, 1823 insertions(+), 11 deletions(-) create mode 100644 backend/internal/pkg/tlsfingerprint/dialer.go create mode 100644 backend/internal/pkg/tlsfingerprint/dialer_test.go create mode 100644 backend/internal/pkg/tlsfingerprint/registry.go create mode 100644 backend/internal/pkg/tlsfingerprint/registry_test.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 655169cc..835db94d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -259,6 +259,33 @@ type GatewayConfig struct { // Scheduling: 账号调度相关配置 Scheduling GatewaySchedulingConfig `mapstructure:"scheduling"` + + // TLSFingerprint: TLS指纹伪装配置 + TLSFingerprint TLSFingerprintConfig `mapstructure:"tls_fingerprint"` +} + +// TLSFingerprintConfig TLS指纹伪装配置 +// 用于模拟 Claude CLI (Node.js) 的 TLS 握手特征,避免被识别为非官方客户端 +type TLSFingerprintConfig struct { + // Enabled: 是否全局启用TLS指纹功能 + Enabled bool `mapstructure:"enabled"` + // Profiles: 预定义的TLS指纹配置模板 + // key 为模板名称,如 "claude_cli_v2", "chrome_120" 等 + Profiles map[string]TLSProfileConfig `mapstructure:"profiles"` +} + +// TLSProfileConfig 单个TLS指纹模板的配置 +type TLSProfileConfig struct { + // Name: 模板显示名称 + Name string `mapstructure:"name"` + // EnableGREASE: 是否启用GREASE扩展(Chrome使用,Node.js不使用) + EnableGREASE bool `mapstructure:"enable_grease"` + // CipherSuites: TLS加密套件列表(空则使用内置默认值) + CipherSuites []uint16 `mapstructure:"cipher_suites"` + // Curves: 椭圆曲线列表(空则使用内置默认值) + Curves []uint16 `mapstructure:"curves"` + // PointFormats: 点格式列表(空则使用内置默认值) + PointFormats []uint8 `mapstructure:"point_formats"` } // GatewaySchedulingConfig accounts scheduling configuration. @@ -787,6 +814,8 @@ func setDefaults() { viper.SetDefault("gateway.scheduling.outbox_lag_rebuild_failures", 3) viper.SetDefault("gateway.scheduling.outbox_backlog_rebuild_rows", 10000) viper.SetDefault("gateway.scheduling.full_rebuild_interval_seconds", 300) + // TLS指纹伪装配置(默认关闭,需要账号级别单独启用) + viper.SetDefault("gateway.tls_fingerprint.enabled", true) viper.SetDefault("concurrency.ping_interval", 10) // TokenRefresh diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index f5bdd008..28965685 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -161,6 +161,11 @@ func AccountFromServiceShallow(a *service.Account) *Account { if idleTimeout := a.GetSessionIdleTimeoutMinutes(); idleTimeout > 0 { out.SessionIdleTimeoutMin = &idleTimeout } + // TLS指纹伪装开关 + if a.IsTLSFingerprintEnabled() { + enabled := true + out.EnableTLSFingerprint = &enabled + } } return out diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 4519143c..93ac2c32 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -112,6 +112,10 @@ type Account struct { MaxSessions *int `json:"max_sessions,omitempty"` SessionIdleTimeoutMin *int `json:"session_idle_timeout_minutes,omitempty"` + // TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效) + // 从 extra 字段提取,方便前端显示和编辑 + EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"` + Proxy *Proxy `json:"proxy,omitempty"` AccountGroups []AccountGroup `json:"account_groups,omitempty"` diff --git a/backend/internal/pkg/tlsfingerprint/dialer.go b/backend/internal/pkg/tlsfingerprint/dialer.go new file mode 100644 index 00000000..bb29ea1c --- /dev/null +++ b/backend/internal/pkg/tlsfingerprint/dialer.go @@ -0,0 +1,564 @@ +// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients. +// It uses the utls library to create TLS connections that mimic Node.js/Claude Code clients. +package tlsfingerprint + +import ( + "bufio" + "context" + "encoding/base64" + "fmt" + "log" + "net" + "net/http" + "net/url" + + "github.com/gin-gonic/gin" + utls "github.com/refraction-networking/utls" + "golang.org/x/net/proxy" +) + +// debugLog prints log only in non-release mode. +func debugLog(format string, v ...any) { + if gin.Mode() != gin.ReleaseMode { + log.Printf(format, v...) + } +} + +// Profile contains TLS fingerprint configuration. +type Profile struct { + Name string // Profile name for identification + CipherSuites []uint16 + Curves []uint16 + PointFormats []uint8 + EnableGREASE bool +} + +// Dialer creates TLS connections with custom fingerprints. +type Dialer struct { + profile *Profile + baseDialer func(ctx context.Context, network, addr string) (net.Conn, error) +} + +// HTTPProxyDialer creates TLS connections through HTTP/HTTPS proxies with custom fingerprints. +// It handles the CONNECT tunnel establishment before performing TLS handshake. +type HTTPProxyDialer struct { + profile *Profile + proxyURL *url.URL +} + +// SOCKS5ProxyDialer creates TLS connections through SOCKS5 proxies with custom fingerprints. +// It uses golang.org/x/net/proxy to establish the SOCKS5 tunnel. +type SOCKS5ProxyDialer struct { + profile *Profile + proxyURL *url.URL +} + +// Default TLS fingerprint values captured from Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x) +// Captured using: tshark -i lo -f "tcp port 8443" -Y "tls.handshake.type == 1" -V +// JA3 Hash: 1a28e69016765d92e3b381168d68922c +// +// Note: JA3/JA4 may have slight variations due to: +// - Session ticket presence/absence +// - Extension negotiation state +var ( + // defaultCipherSuites contains all 59 cipher suites from Claude CLI + // Order is critical for JA3 fingerprint matching + defaultCipherSuites = []uint16{ + // TLS 1.3 cipher suites (MUST be first) + 0x1302, // TLS_AES_256_GCM_SHA384 + 0x1303, // TLS_CHACHA20_POLY1305_SHA256 + 0x1301, // TLS_AES_128_GCM_SHA256 + + // ECDHE + AES-GCM + 0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 + 0xc02b, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + 0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + 0xc02c, // TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 + + // DHE + AES-GCM + 0x009e, // TLS_DHE_RSA_WITH_AES_128_GCM_SHA256 + + // ECDHE/DHE + AES-CBC-SHA256/384 + 0xc027, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256 + 0x0067, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA256 + 0xc028, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384 + 0x006b, // TLS_DHE_RSA_WITH_AES_256_CBC_SHA256 + + // DHE-DSS/RSA + AES-GCM + 0x00a3, // TLS_DHE_DSS_WITH_AES_256_GCM_SHA384 + 0x009f, // TLS_DHE_RSA_WITH_AES_256_GCM_SHA384 + + // ChaCha20-Poly1305 + 0xcca9, // TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 + 0xcca8, // TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 + 0xccaa, // TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256 + + // AES-CCM (256-bit) + 0xc0af, // TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8 + 0xc0ad, // TLS_ECDHE_ECDSA_WITH_AES_256_CCM + 0xc0a3, // TLS_DHE_RSA_WITH_AES_256_CCM_8 + 0xc09f, // TLS_DHE_RSA_WITH_AES_256_CCM + + // ARIA (256-bit) + 0xc05d, // TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384 + 0xc061, // TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384 + 0xc057, // TLS_DHE_DSS_WITH_ARIA_256_GCM_SHA384 + 0xc053, // TLS_DHE_RSA_WITH_ARIA_256_GCM_SHA384 + + // DHE-DSS + AES-GCM (128-bit) + 0x00a2, // TLS_DHE_DSS_WITH_AES_128_GCM_SHA256 + + // AES-CCM (128-bit) + 0xc0ae, // TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8 + 0xc0ac, // TLS_ECDHE_ECDSA_WITH_AES_128_CCM + 0xc0a2, // TLS_DHE_RSA_WITH_AES_128_CCM_8 + 0xc09e, // TLS_DHE_RSA_WITH_AES_128_CCM + + // ARIA (128-bit) + 0xc05c, // TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256 + 0xc060, // TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256 + 0xc056, // TLS_DHE_DSS_WITH_ARIA_128_GCM_SHA256 + 0xc052, // TLS_DHE_RSA_WITH_ARIA_128_GCM_SHA256 + + // ECDHE/DHE + AES-CBC-SHA384/256 (more) + 0xc024, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384 + 0x006a, // TLS_DHE_DSS_WITH_AES_256_CBC_SHA256 + 0xc023, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 + 0x0040, // TLS_DHE_DSS_WITH_AES_128_CBC_SHA256 + + // ECDHE/DHE + AES-CBC-SHA (legacy) + 0xc00a, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA + 0xc014, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA + 0x0039, // TLS_DHE_RSA_WITH_AES_256_CBC_SHA + 0x0038, // TLS_DHE_DSS_WITH_AES_256_CBC_SHA + 0xc009, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA + 0xc013, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA + 0x0033, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA + 0x0032, // TLS_DHE_DSS_WITH_AES_128_CBC_SHA + + // RSA + AES-GCM/CCM/ARIA (non-PFS, 256-bit) + 0x009d, // TLS_RSA_WITH_AES_256_GCM_SHA384 + 0xc0a1, // TLS_RSA_WITH_AES_256_CCM_8 + 0xc09d, // TLS_RSA_WITH_AES_256_CCM + 0xc051, // TLS_RSA_WITH_ARIA_256_GCM_SHA384 + + // RSA + AES-GCM/CCM/ARIA (non-PFS, 128-bit) + 0x009c, // TLS_RSA_WITH_AES_128_GCM_SHA256 + 0xc0a0, // TLS_RSA_WITH_AES_128_CCM_8 + 0xc09c, // TLS_RSA_WITH_AES_128_CCM + 0xc050, // TLS_RSA_WITH_ARIA_128_GCM_SHA256 + + // RSA + AES-CBC (non-PFS, legacy) + 0x003d, // TLS_RSA_WITH_AES_256_CBC_SHA256 + 0x003c, // TLS_RSA_WITH_AES_128_CBC_SHA256 + 0x0035, // TLS_RSA_WITH_AES_256_CBC_SHA + 0x002f, // TLS_RSA_WITH_AES_128_CBC_SHA + + // Renegotiation indication + 0x00ff, // TLS_EMPTY_RENEGOTIATION_INFO_SCSV + } + + // defaultCurves contains the 10 supported groups from Claude CLI (including FFDHE) + defaultCurves = []utls.CurveID{ + utls.X25519, // 0x001d + utls.CurveP256, // 0x0017 (secp256r1) + utls.CurveID(0x001e), // x448 + utls.CurveP521, // 0x0019 (secp521r1) + utls.CurveP384, // 0x0018 (secp384r1) + utls.CurveID(0x0100), // ffdhe2048 + utls.CurveID(0x0101), // ffdhe3072 + utls.CurveID(0x0102), // ffdhe4096 + utls.CurveID(0x0103), // ffdhe6144 + utls.CurveID(0x0104), // ffdhe8192 + } + + // defaultPointFormats contains all 3 point formats from Claude CLI + defaultPointFormats = []uint8{ + 0, // uncompressed + 1, // ansiX962_compressed_prime + 2, // ansiX962_compressed_char2 + } + + // defaultSignatureAlgorithms contains the 20 signature algorithms from Claude CLI + defaultSignatureAlgorithms = []utls.SignatureScheme{ + 0x0403, // ecdsa_secp256r1_sha256 + 0x0503, // ecdsa_secp384r1_sha384 + 0x0603, // ecdsa_secp521r1_sha512 + 0x0807, // ed25519 + 0x0808, // ed448 + 0x0809, // rsa_pss_pss_sha256 + 0x080a, // rsa_pss_pss_sha384 + 0x080b, // rsa_pss_pss_sha512 + 0x0804, // rsa_pss_rsae_sha256 + 0x0805, // rsa_pss_rsae_sha384 + 0x0806, // rsa_pss_rsae_sha512 + 0x0401, // rsa_pkcs1_sha256 + 0x0501, // rsa_pkcs1_sha384 + 0x0601, // rsa_pkcs1_sha512 + 0x0303, // ecdsa_sha224 + 0x0301, // rsa_pkcs1_sha224 + 0x0302, // dsa_sha224 + 0x0402, // dsa_sha256 + 0x0502, // dsa_sha384 + 0x0602, // dsa_sha512 + } +) + +// NewDialer creates a new TLS fingerprint dialer. +// baseDialer is used for TCP connection establishment (supports proxy scenarios). +// If baseDialer is nil, direct TCP dial is used. +func NewDialer(profile *Profile, baseDialer func(ctx context.Context, network, addr string) (net.Conn, error)) *Dialer { + if baseDialer == nil { + baseDialer = (&net.Dialer{}).DialContext + } + return &Dialer{profile: profile, baseDialer: baseDialer} +} + +// NewHTTPProxyDialer creates a new TLS fingerprint dialer that works through HTTP/HTTPS proxies. +// It establishes a CONNECT tunnel before performing TLS handshake with custom fingerprint. +func NewHTTPProxyDialer(profile *Profile, proxyURL *url.URL) *HTTPProxyDialer { + return &HTTPProxyDialer{profile: profile, proxyURL: proxyURL} +} + +// NewSOCKS5ProxyDialer creates a new TLS fingerprint dialer that works through SOCKS5 proxies. +// It establishes a SOCKS5 tunnel before performing TLS handshake with custom fingerprint. +func NewSOCKS5ProxyDialer(profile *Profile, proxyURL *url.URL) *SOCKS5ProxyDialer { + return &SOCKS5ProxyDialer{profile: profile, proxyURL: proxyURL} +} + +// DialTLSContext establishes a TLS connection through SOCKS5 proxy with the configured fingerprint. +// Flow: SOCKS5 CONNECT to target -> TLS handshake with utls on the tunnel +func (d *SOCKS5ProxyDialer) DialTLSContext(ctx context.Context, network, addr string) (net.Conn, error) { + debugLog("[TLS Fingerprint SOCKS5] Connecting through proxy %s for target %s", d.proxyURL.Host, addr) + + // Step 1: Create SOCKS5 dialer + var auth *proxy.Auth + if d.proxyURL.User != nil { + username := d.proxyURL.User.Username() + password, _ := d.proxyURL.User.Password() + auth = &proxy.Auth{ + User: username, + Password: password, + } + } + + // Determine proxy address + proxyAddr := d.proxyURL.Host + if d.proxyURL.Port() == "" { + proxyAddr = net.JoinHostPort(d.proxyURL.Hostname(), "1080") // Default SOCKS5 port + } + + socksDialer, err := proxy.SOCKS5("tcp", proxyAddr, auth, proxy.Direct) + if err != nil { + debugLog("[TLS Fingerprint SOCKS5] Failed to create SOCKS5 dialer: %v", err) + return nil, fmt.Errorf("create SOCKS5 dialer: %w", err) + } + + // Step 2: Establish SOCKS5 tunnel to target + debugLog("[TLS Fingerprint SOCKS5] Establishing SOCKS5 tunnel to %s", addr) + conn, err := socksDialer.Dial("tcp", addr) + if err != nil { + debugLog("[TLS Fingerprint SOCKS5] Failed to connect through SOCKS5: %v", err) + return nil, fmt.Errorf("SOCKS5 connect: %w", err) + } + debugLog("[TLS Fingerprint SOCKS5] SOCKS5 tunnel established") + + // Step 3: Perform TLS handshake on the tunnel with utls fingerprint + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + debugLog("[TLS Fingerprint SOCKS5] Starting TLS handshake to %s", host) + + // Build ClientHello specification from profile (Node.js/Claude CLI fingerprint) + spec := buildClientHelloSpecFromProfile(d.profile) + debugLog("[TLS Fingerprint SOCKS5] ClientHello spec: CipherSuites=%d, Extensions=%d, CompressionMethods=%v, TLSVersMax=0x%04x, TLSVersMin=0x%04x", + len(spec.CipherSuites), len(spec.Extensions), spec.CompressionMethods, spec.TLSVersMax, spec.TLSVersMin) + + if d.profile != nil { + debugLog("[TLS Fingerprint SOCKS5] Using profile: %s, GREASE: %v", d.profile.Name, d.profile.EnableGREASE) + } + + // Create uTLS connection on the tunnel + tlsConn := utls.UClient(conn, &utls.Config{ + ServerName: host, + }, utls.HelloCustom) + + if err := tlsConn.ApplyPreset(spec); err != nil { + debugLog("[TLS Fingerprint SOCKS5] ApplyPreset failed: %v", err) + _ = conn.Close() + return nil, fmt.Errorf("apply TLS preset: %w", err) + } + + if err := tlsConn.Handshake(); err != nil { + debugLog("[TLS Fingerprint SOCKS5] Handshake FAILED: %v", err) + _ = conn.Close() + return nil, fmt.Errorf("TLS handshake failed: %w", err) + } + + state := tlsConn.ConnectionState() + debugLog("[TLS Fingerprint SOCKS5] Handshake SUCCESS - Version: 0x%04x, CipherSuite: 0x%04x, ALPN: %s", + state.Version, state.CipherSuite, state.NegotiatedProtocol) + + return tlsConn, nil +} + +// DialTLSContext establishes a TLS connection through HTTP proxy with the configured fingerprint. +// Flow: TCP connect to proxy -> CONNECT tunnel -> TLS handshake with utls +func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr string) (net.Conn, error) { + debugLog("[TLS Fingerprint HTTPProxy] Connecting to proxy %s for target %s", d.proxyURL.Host, addr) + + // Step 1: TCP connect to proxy server + var proxyAddr string + if d.proxyURL.Port() != "" { + proxyAddr = d.proxyURL.Host + } else { + // Default ports + if d.proxyURL.Scheme == "https" { + proxyAddr = net.JoinHostPort(d.proxyURL.Hostname(), "443") + } else { + proxyAddr = net.JoinHostPort(d.proxyURL.Hostname(), "80") + } + } + + dialer := &net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", proxyAddr) + if err != nil { + debugLog("[TLS Fingerprint HTTPProxy] Failed to connect to proxy: %v", err) + return nil, fmt.Errorf("connect to proxy: %w", err) + } + debugLog("[TLS Fingerprint HTTPProxy] Connected to proxy %s", proxyAddr) + + // Step 2: Send CONNECT request to establish tunnel + req := &http.Request{ + Method: "CONNECT", + URL: &url.URL{Opaque: addr}, + Host: addr, + Header: make(http.Header), + } + + // Add proxy authentication if present + if d.proxyURL.User != nil { + username := d.proxyURL.User.Username() + password, _ := d.proxyURL.User.Password() + auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + req.Header.Set("Proxy-Authorization", "Basic "+auth) + } + + debugLog("[TLS Fingerprint HTTPProxy] Sending CONNECT request for %s", addr) + if err := req.Write(conn); err != nil { + _ = conn.Close() + debugLog("[TLS Fingerprint HTTPProxy] Failed to write CONNECT request: %v", err) + return nil, fmt.Errorf("write CONNECT request: %w", err) + } + + // Step 3: Read CONNECT response + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, req) + if err != nil { + _ = conn.Close() + debugLog("[TLS Fingerprint HTTPProxy] Failed to read CONNECT response: %v", err) + return nil, fmt.Errorf("read CONNECT response: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + _ = conn.Close() + debugLog("[TLS Fingerprint HTTPProxy] CONNECT failed with status: %d %s", resp.StatusCode, resp.Status) + return nil, fmt.Errorf("proxy CONNECT failed: %s", resp.Status) + } + debugLog("[TLS Fingerprint HTTPProxy] CONNECT tunnel established") + + // Step 4: Perform TLS handshake on the tunnel with utls fingerprint + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + debugLog("[TLS Fingerprint HTTPProxy] Starting TLS handshake to %s", host) + + // Build ClientHello specification (reuse the shared method) + spec := buildClientHelloSpecFromProfile(d.profile) + debugLog("[TLS Fingerprint HTTPProxy] ClientHello spec built with %d cipher suites, %d extensions", + len(spec.CipherSuites), len(spec.Extensions)) + + if d.profile != nil { + debugLog("[TLS Fingerprint HTTPProxy] Using profile: %s, GREASE: %v", d.profile.Name, d.profile.EnableGREASE) + } + + // Create uTLS connection on the tunnel + // Note: TLS 1.3 cipher suites are handled automatically by utls when TLS 1.3 is in SupportedVersions + tlsConn := utls.UClient(conn, &utls.Config{ + ServerName: host, + }, utls.HelloCustom) + + if err := tlsConn.ApplyPreset(spec); err != nil { + debugLog("[TLS Fingerprint HTTPProxy] ApplyPreset failed: %v", err) + _ = conn.Close() + return nil, fmt.Errorf("apply TLS preset: %w", err) + } + + if err := tlsConn.HandshakeContext(ctx); err != nil { + debugLog("[TLS Fingerprint HTTPProxy] Handshake FAILED: %v", err) + _ = conn.Close() + return nil, fmt.Errorf("TLS handshake failed: %w", err) + } + + state := tlsConn.ConnectionState() + debugLog("[TLS Fingerprint HTTPProxy] Handshake SUCCESS - Version: 0x%04x, CipherSuite: 0x%04x, ALPN: %s", + state.Version, state.CipherSuite, state.NegotiatedProtocol) + + return tlsConn, nil +} + +// DialTLSContext establishes a TLS connection with the configured fingerprint. +// This method is designed to be used as http.Transport.DialTLSContext. +func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net.Conn, error) { + // Establish TCP connection using base dialer (supports proxy) + debugLog("[TLS Fingerprint] Dialing TCP to %s", addr) + conn, err := d.baseDialer(ctx, network, addr) + if err != nil { + debugLog("[TLS Fingerprint] TCP dial failed: %v", err) + return nil, err + } + debugLog("[TLS Fingerprint] TCP connected to %s", addr) + + // Extract hostname for SNI + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + debugLog("[TLS Fingerprint] SNI hostname: %s", host) + + // Build ClientHello specification + spec := d.buildClientHelloSpec() + debugLog("[TLS Fingerprint] ClientHello spec built with %d cipher suites, %d extensions", + len(spec.CipherSuites), len(spec.Extensions)) + + // Log profile info + if d.profile != nil { + debugLog("[TLS Fingerprint] Using profile: %s, GREASE: %v", d.profile.Name, d.profile.EnableGREASE) + } else { + debugLog("[TLS Fingerprint] Using default profile (no custom config)") + } + + // Create uTLS connection + // Note: TLS 1.3 cipher suites are handled automatically by utls when TLS 1.3 is in SupportedVersions + tlsConn := utls.UClient(conn, &utls.Config{ + ServerName: host, + }, utls.HelloCustom) + + // Apply fingerprint + if err := tlsConn.ApplyPreset(spec); err != nil { + debugLog("[TLS Fingerprint] ApplyPreset failed: %v", err) + _ = conn.Close() + return nil, err + } + debugLog("[TLS Fingerprint] Preset applied, starting handshake...") + + // Perform TLS handshake + if err := tlsConn.HandshakeContext(ctx); err != nil { + debugLog("[TLS Fingerprint] Handshake FAILED: %v", err) + // Log more details about the connection state + debugLog("[TLS Fingerprint] Connection state - Local: %v, Remote: %v", + conn.LocalAddr(), conn.RemoteAddr()) + _ = conn.Close() + return nil, fmt.Errorf("TLS handshake failed: %w", err) + } + + // Log successful handshake details + state := tlsConn.ConnectionState() + debugLog("[TLS Fingerprint] Handshake SUCCESS - Version: 0x%04x, CipherSuite: 0x%04x, ALPN: %s", + state.Version, state.CipherSuite, state.NegotiatedProtocol) + + return tlsConn, nil +} + +// buildClientHelloSpec constructs the ClientHello specification based on the profile. +func (d *Dialer) buildClientHelloSpec() *utls.ClientHelloSpec { + return buildClientHelloSpecFromProfile(d.profile) +} + +// toUTLSCurves converts uint16 slice to utls.CurveID slice. +func toUTLSCurves(curves []uint16) []utls.CurveID { + result := make([]utls.CurveID, len(curves)) + for i, c := range curves { + result[i] = utls.CurveID(c) + } + return result +} + +// buildClientHelloSpecFromProfile constructs ClientHelloSpec from a Profile. +// This is a standalone function that can be used by both Dialer and HTTPProxyDialer. +func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec { + // Get cipher suites + var cipherSuites []uint16 + if profile != nil && len(profile.CipherSuites) > 0 { + cipherSuites = profile.CipherSuites + } else { + cipherSuites = defaultCipherSuites + } + + // Get curves + var curves []utls.CurveID + if profile != nil && len(profile.Curves) > 0 { + curves = toUTLSCurves(profile.Curves) + } else { + curves = defaultCurves + } + + // Get point formats + var pointFormats []uint8 + if profile != nil && len(profile.PointFormats) > 0 { + pointFormats = profile.PointFormats + } else { + pointFormats = defaultPointFormats + } + + // Check if GREASE is enabled + enableGREASE := profile != nil && profile.EnableGREASE + + extensions := make([]utls.TLSExtension, 0, 16) + + if enableGREASE { + extensions = append(extensions, &utls.UtlsGREASEExtension{}) + } + + // SNI extension - MUST be explicitly added for HelloCustom mode + // utls will populate the server name from Config.ServerName + extensions = append(extensions, &utls.SNIExtension{}) + + // Claude CLI extension order (captured from tshark): + // server_name(0), ec_point_formats(11), supported_groups(10), session_ticket(35), + // alpn(16), encrypt_then_mac(22), extended_master_secret(23), + // signature_algorithms(13), supported_versions(43), + // psk_key_exchange_modes(45), key_share(51) + extensions = append(extensions, + &utls.SupportedPointsExtension{SupportedPoints: pointFormats}, + &utls.SupportedCurvesExtension{Curves: curves}, + &utls.SessionTicketExtension{}, + &utls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}}, + &utls.GenericExtension{Id: 22}, + &utls.ExtendedMasterSecretExtension{}, + &utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: defaultSignatureAlgorithms}, + &utls.SupportedVersionsExtension{Versions: []uint16{ + utls.VersionTLS13, + utls.VersionTLS12, + }}, + &utls.PSKKeyExchangeModesExtension{Modes: []uint8{utls.PskModeDHE}}, + &utls.KeyShareExtension{KeyShares: []utls.KeyShare{ + {Group: utls.X25519}, + }}, + ) + + if enableGREASE { + extensions = append(extensions, &utls.UtlsGREASEExtension{}) + } + + return &utls.ClientHelloSpec{ + CipherSuites: cipherSuites, + CompressionMethods: []uint8{0}, // null compression only (standard) + Extensions: extensions, + TLSVersMax: utls.VersionTLS13, + TLSVersMin: utls.VersionTLS10, + } +} diff --git a/backend/internal/pkg/tlsfingerprint/dialer_test.go b/backend/internal/pkg/tlsfingerprint/dialer_test.go new file mode 100644 index 00000000..2aed1287 --- /dev/null +++ b/backend/internal/pkg/tlsfingerprint/dialer_test.go @@ -0,0 +1,307 @@ +// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients. +// +// Integration tests for verifying TLS fingerprint correctness. +// These tests make actual network requests and should be run manually. +// +// Run with: go test -v ./internal/pkg/tlsfingerprint/... +// Run integration tests: go test -v -run TestJA3 ./internal/pkg/tlsfingerprint/... +package tlsfingerprint + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +// FingerprintResponse represents the response from tls.peet.ws/api/all. +type FingerprintResponse struct { + IP string `json:"ip"` + TLS TLSInfo `json:"tls"` + HTTP2 any `json:"http2"` +} + +// TLSInfo contains TLS fingerprint details. +type TLSInfo struct { + JA3 string `json:"ja3"` + JA3Hash string `json:"ja3_hash"` + JA4 string `json:"ja4"` + PeetPrint string `json:"peetprint"` + PeetPrintHash string `json:"peetprint_hash"` + ClientRandom string `json:"client_random"` + SessionID string `json:"session_id"` +} + +// TestDialerBasicConnection tests that the dialer can establish TLS connections. +func TestDialerBasicConnection(t *testing.T) { + if testing.Short() { + t.Skip("skipping network test in short mode") + } + + // Create a dialer with default profile + profile := &Profile{ + Name: "Test Profile", + EnableGREASE: false, + } + dialer := NewDialer(profile, nil) + + // Create HTTP client with custom TLS dialer + client := &http.Client{ + Transport: &http.Transport{ + DialTLSContext: dialer.DialTLSContext, + }, + Timeout: 30 * time.Second, + } + + // Make a request to a known HTTPS endpoint + resp, err := client.Get("https://www.google.com") + if err != nil { + t.Fatalf("failed to connect: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } +} + +// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value. +// This test uses tls.peet.ws to verify the fingerprint. +// Expected JA3 hash: 1a28e69016765d92e3b381168d68922c (Claude CLI / Node.js 20.x) +// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP) +func TestJA3Fingerprint(t *testing.T) { + // Skip if network is unavailable or if running in short mode + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + profile := &Profile{ + Name: "Claude CLI Test", + EnableGREASE: false, + } + dialer := NewDialer(profile, nil) + + client := &http.Client{ + Transport: &http.Transport{ + DialTLSContext: dialer.DialTLSContext, + }, + Timeout: 30 * time.Second, + } + + // Use tls.peet.ws fingerprint detection API + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", "https://tls.peet.ws/api/all", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0") + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("failed to get fingerprint: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response: %v", err) + } + + var fpResp FingerprintResponse + if err := json.Unmarshal(body, &fpResp); err != nil { + t.Logf("Response body: %s", string(body)) + t.Fatalf("failed to parse fingerprint response: %v", err) + } + + // Log all fingerprint information + t.Logf("JA3: %s", fpResp.TLS.JA3) + t.Logf("JA3 Hash: %s", fpResp.TLS.JA3Hash) + t.Logf("JA4: %s", fpResp.TLS.JA4) + t.Logf("PeetPrint: %s", fpResp.TLS.PeetPrint) + t.Logf("PeetPrint Hash: %s", fpResp.TLS.PeetPrintHash) + + // Verify JA3 hash matches expected value + expectedJA3Hash := "1a28e69016765d92e3b381168d68922c" + if fpResp.TLS.JA3Hash == expectedJA3Hash { + t.Logf("✓ JA3 hash matches expected value: %s", expectedJA3Hash) + } else { + t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash) + } + + // Verify JA4 fingerprint + // JA4 format: t[version][sni][cipher_count][ext_count][alpn]_[cipher_hash]_[ext_hash] + // Expected: t13d5910h1 (d=domain) or t13i5910h1 (i=IP) + // The suffix _a33745022dd6_1f22a2ca17c4 should match + expectedJA4Suffix := "_a33745022dd6_1f22a2ca17c4" + if strings.HasSuffix(fpResp.TLS.JA4, expectedJA4Suffix) { + t.Logf("✓ JA4 suffix matches expected value: %s", expectedJA4Suffix) + } else { + t.Errorf("✗ JA4 suffix mismatch: got %s, expected suffix %s", fpResp.TLS.JA4, expectedJA4Suffix) + } + + // Verify JA4 prefix (t13d5911h1 or t13i5911h1) + // d = domain (SNI present), i = IP (no SNI) + // Since we connect to tls.peet.ws (domain), we expect 'd' + expectedJA4Prefix := "t13d5911h1" + if strings.HasPrefix(fpResp.TLS.JA4, expectedJA4Prefix) { + t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 59=ciphers, 11=extensions, h1=HTTP/1.1)", expectedJA4Prefix) + } else { + // Also accept 'i' variant for IP connections + altPrefix := "t13i5911h1" + if strings.HasPrefix(fpResp.TLS.JA4, altPrefix) { + t.Logf("✓ JA4 prefix matches (IP variant): %s", altPrefix) + } else { + t.Errorf("✗ JA4 prefix mismatch: got %s, expected %s or %s", fpResp.TLS.JA4, expectedJA4Prefix, altPrefix) + } + } + + // Verify JA3 contains expected cipher suites (TLS 1.3 ciphers at the beginning) + if strings.Contains(fpResp.TLS.JA3, "4866-4867-4865") { + t.Logf("✓ JA3 contains expected TLS 1.3 cipher suites") + } else { + t.Logf("Warning: JA3 does not contain expected TLS 1.3 cipher suites") + } + + // Verify extension list (should be 11 extensions including SNI) + // Expected: 0-11-10-35-16-22-23-13-43-45-51 + expectedExtensions := "0-11-10-35-16-22-23-13-43-45-51" + if strings.Contains(fpResp.TLS.JA3, expectedExtensions) { + t.Logf("✓ JA3 contains expected extension list: %s", expectedExtensions) + } else { + t.Logf("Warning: JA3 extension list may differ") + } +} + +// TestDialerWithProfile tests that different profiles produce different fingerprints. +func TestDialerWithProfile(t *testing.T) { + // Create two dialers with different profiles + profile1 := &Profile{ + Name: "Profile 1 - No GREASE", + EnableGREASE: false, + } + profile2 := &Profile{ + Name: "Profile 2 - With GREASE", + EnableGREASE: true, + } + + dialer1 := NewDialer(profile1, nil) + dialer2 := NewDialer(profile2, nil) + + // Build specs and compare + // Note: We can't directly compare JA3 without making network requests + // but we can verify the specs are different + spec1 := dialer1.buildClientHelloSpec() + spec2 := dialer2.buildClientHelloSpec() + + // Profile with GREASE should have more extensions + if len(spec2.Extensions) <= len(spec1.Extensions) { + t.Error("expected GREASE profile to have more extensions") + } +} + +// TestHTTPProxyDialerBasic tests HTTP proxy dialer creation. +// Note: This is a unit test - actual proxy testing requires a proxy server. +func TestHTTPProxyDialerBasic(t *testing.T) { + profile := &Profile{ + Name: "Test Profile", + EnableGREASE: false, + } + + // Test that dialer is created without panic + proxyURL := mustParseURL("http://proxy.example.com:8080") + dialer := NewHTTPProxyDialer(profile, proxyURL) + + if dialer == nil { + t.Fatal("expected dialer to be created") + } + if dialer.profile != profile { + t.Error("expected profile to be set") + } + if dialer.proxyURL != proxyURL { + t.Error("expected proxyURL to be set") + } +} + +// TestSOCKS5ProxyDialerBasic tests SOCKS5 proxy dialer creation. +// Note: This is a unit test - actual proxy testing requires a proxy server. +func TestSOCKS5ProxyDialerBasic(t *testing.T) { + profile := &Profile{ + Name: "Test Profile", + EnableGREASE: false, + } + + // Test that dialer is created without panic + proxyURL := mustParseURL("socks5://proxy.example.com:1080") + dialer := NewSOCKS5ProxyDialer(profile, proxyURL) + + if dialer == nil { + t.Fatal("expected dialer to be created") + } + if dialer.profile != profile { + t.Error("expected profile to be set") + } + if dialer.proxyURL != proxyURL { + t.Error("expected proxyURL to be set") + } +} + +// TestBuildClientHelloSpec tests ClientHello spec construction. +func TestBuildClientHelloSpec(t *testing.T) { + // Test with nil profile (should use defaults) + spec := buildClientHelloSpecFromProfile(nil) + + if len(spec.CipherSuites) == 0 { + t.Error("expected cipher suites to be set") + } + if len(spec.Extensions) == 0 { + t.Error("expected extensions to be set") + } + + // Verify default cipher suites are used + if len(spec.CipherSuites) != len(defaultCipherSuites) { + t.Errorf("expected %d cipher suites, got %d", len(defaultCipherSuites), len(spec.CipherSuites)) + } + + // Test with custom profile + customProfile := &Profile{ + Name: "Custom", + EnableGREASE: false, + CipherSuites: []uint16{0x1301, 0x1302}, + } + spec = buildClientHelloSpecFromProfile(customProfile) + + if len(spec.CipherSuites) != 2 { + t.Errorf("expected 2 cipher suites, got %d", len(spec.CipherSuites)) + } +} + +// TestToUTLSCurves tests curve ID conversion. +func TestToUTLSCurves(t *testing.T) { + input := []uint16{0x001d, 0x0017, 0x0018} + result := toUTLSCurves(input) + + if len(result) != len(input) { + t.Errorf("expected %d curves, got %d", len(input), len(result)) + } + + for i, curve := range result { + if uint16(curve) != input[i] { + t.Errorf("curve %d: expected 0x%04x, got 0x%04x", i, input[i], uint16(curve)) + } + } +} + +// Helper function to parse URL without error handling. +func mustParseURL(rawURL string) *url.URL { + u, err := url.Parse(rawURL) + if err != nil { + panic(err) + } + return u +} diff --git a/backend/internal/pkg/tlsfingerprint/registry.go b/backend/internal/pkg/tlsfingerprint/registry.go new file mode 100644 index 00000000..db5afeba --- /dev/null +++ b/backend/internal/pkg/tlsfingerprint/registry.go @@ -0,0 +1,170 @@ +// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients. +package tlsfingerprint + +import ( + "sort" + "sync" + + "github.com/Wei-Shaw/sub2api/internal/config" +) + +// DefaultProfileName is the name of the built-in Claude CLI profile. +const DefaultProfileName = "claude_cli_v2" + +// Registry manages TLS fingerprint profiles. +// It holds a collection of profiles that can be used for TLS fingerprint simulation. +// Profiles are selected based on account ID using modulo operation. +type Registry struct { + mu sync.RWMutex + profiles map[string]*Profile + profileNames []string // Sorted list of profile names for deterministic selection +} + +// NewRegistry creates a new TLS fingerprint profile registry. +// It initializes with the built-in default profile. +func NewRegistry() *Registry { + r := &Registry{ + profiles: make(map[string]*Profile), + profileNames: make([]string, 0), + } + + // Register the built-in default profile + r.registerBuiltinProfile() + + return r +} + +// NewRegistryFromConfig creates a new registry and loads profiles from config. +// If the config has custom profiles defined, they will be merged with the built-in default. +func NewRegistryFromConfig(cfg *config.TLSFingerprintConfig) *Registry { + r := NewRegistry() + + if cfg == nil || !cfg.Enabled { + debugLog("[TLS Registry] TLS fingerprint disabled or no config, using default profile only") + return r + } + + // Load custom profiles from config + for name, profileCfg := range cfg.Profiles { + profile := &Profile{ + Name: profileCfg.Name, + EnableGREASE: profileCfg.EnableGREASE, + CipherSuites: profileCfg.CipherSuites, + Curves: profileCfg.Curves, + PointFormats: profileCfg.PointFormats, + } + + // If the profile has empty values, they will use defaults in dialer + r.RegisterProfile(name, profile) + debugLog("[TLS Registry] Loaded custom profile: %s (%s)", name, profileCfg.Name) + } + + debugLog("[TLS Registry] Initialized with %d profiles: %v", len(r.profileNames), r.profileNames) + return r +} + +// registerBuiltinProfile adds the default Claude CLI profile to the registry. +func (r *Registry) registerBuiltinProfile() { + defaultProfile := &Profile{ + Name: "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)", + EnableGREASE: false, // Node.js does not use GREASE + // Empty slices will cause dialer to use built-in defaults + CipherSuites: nil, + Curves: nil, + PointFormats: nil, + } + r.RegisterProfile(DefaultProfileName, defaultProfile) +} + +// RegisterProfile adds or updates a profile in the registry. +func (r *Registry) RegisterProfile(name string, profile *Profile) { + r.mu.Lock() + defer r.mu.Unlock() + + // Check if this is a new profile + _, exists := r.profiles[name] + r.profiles[name] = profile + + if !exists { + r.profileNames = append(r.profileNames, name) + // Keep names sorted for deterministic selection + sort.Strings(r.profileNames) + } +} + +// GetProfile returns a profile by name. +// Returns nil if the profile does not exist. +func (r *Registry) GetProfile(name string) *Profile { + r.mu.RLock() + defer r.mu.RUnlock() + return r.profiles[name] +} + +// GetDefaultProfile returns the built-in default profile. +func (r *Registry) GetDefaultProfile() *Profile { + return r.GetProfile(DefaultProfileName) +} + +// GetProfileByAccountID returns a profile for the given account ID. +// The profile is selected using: profileNames[accountID % len(profiles)] +// This ensures deterministic profile assignment for each account. +func (r *Registry) GetProfileByAccountID(accountID int64) *Profile { + r.mu.RLock() + defer r.mu.RUnlock() + + if len(r.profileNames) == 0 { + return nil + } + + // Use modulo to select profile index + // Use absolute value to handle negative IDs (though unlikely) + idx := accountID + if idx < 0 { + idx = -idx + } + selectedIndex := int(idx % int64(len(r.profileNames))) + selectedName := r.profileNames[selectedIndex] + + return r.profiles[selectedName] +} + +// ProfileCount returns the number of registered profiles. +func (r *Registry) ProfileCount() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.profiles) +} + +// ProfileNames returns a sorted list of all registered profile names. +func (r *Registry) ProfileNames() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + // Return a copy to prevent modification + names := make([]string, len(r.profileNames)) + copy(names, r.profileNames) + return names +} + +// Global registry instance for convenience +var globalRegistry *Registry +var globalRegistryOnce sync.Once + +// GlobalRegistry returns the global TLS fingerprint registry. +// The registry is lazily initialized with the default profile. +func GlobalRegistry() *Registry { + globalRegistryOnce.Do(func() { + globalRegistry = NewRegistry() + }) + return globalRegistry +} + +// InitGlobalRegistry initializes the global registry with configuration. +// This should be called during application startup. +// It is safe to call multiple times; subsequent calls will update the registry. +func InitGlobalRegistry(cfg *config.TLSFingerprintConfig) *Registry { + globalRegistryOnce.Do(func() { + globalRegistry = NewRegistryFromConfig(cfg) + }) + return globalRegistry +} diff --git a/backend/internal/pkg/tlsfingerprint/registry_test.go b/backend/internal/pkg/tlsfingerprint/registry_test.go new file mode 100644 index 00000000..752ba0cc --- /dev/null +++ b/backend/internal/pkg/tlsfingerprint/registry_test.go @@ -0,0 +1,243 @@ +package tlsfingerprint + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" +) + +func TestNewRegistry(t *testing.T) { + r := NewRegistry() + + // Should have exactly one profile (the default) + if r.ProfileCount() != 1 { + t.Errorf("expected 1 profile, got %d", r.ProfileCount()) + } + + // Should have the default profile + profile := r.GetDefaultProfile() + if profile == nil { + t.Error("expected default profile to exist") + } + + // Default profile name should be in the list + names := r.ProfileNames() + if len(names) != 1 || names[0] != DefaultProfileName { + t.Errorf("expected profile names to be [%s], got %v", DefaultProfileName, names) + } +} + +func TestRegisterProfile(t *testing.T) { + r := NewRegistry() + + // Register a new profile + customProfile := &Profile{ + Name: "Custom Profile", + EnableGREASE: true, + } + r.RegisterProfile("custom", customProfile) + + // Should now have 2 profiles + if r.ProfileCount() != 2 { + t.Errorf("expected 2 profiles, got %d", r.ProfileCount()) + } + + // Should be able to retrieve the custom profile + retrieved := r.GetProfile("custom") + if retrieved == nil { + t.Fatal("expected custom profile to exist") + } + if retrieved.Name != "Custom Profile" { + t.Errorf("expected profile name 'Custom Profile', got '%s'", retrieved.Name) + } + if !retrieved.EnableGREASE { + t.Error("expected EnableGREASE to be true") + } +} + +func TestGetProfile(t *testing.T) { + r := NewRegistry() + + // Get existing profile + profile := r.GetProfile(DefaultProfileName) + if profile == nil { + t.Error("expected default profile to exist") + } + + // Get non-existing profile + nonExistent := r.GetProfile("nonexistent") + if nonExistent != nil { + t.Error("expected nil for non-existent profile") + } +} + +func TestGetProfileByAccountID(t *testing.T) { + r := NewRegistry() + + // With only default profile, all account IDs should return the same profile + for i := int64(0); i < 10; i++ { + profile := r.GetProfileByAccountID(i) + if profile == nil { + t.Errorf("expected profile for account %d, got nil", i) + } + } + + // Add more profiles + r.RegisterProfile("profile_a", &Profile{Name: "Profile A"}) + r.RegisterProfile("profile_b", &Profile{Name: "Profile B"}) + + // Now we have 3 profiles: claude_cli_v2, profile_a, profile_b + // Names are sorted, so order is: claude_cli_v2, profile_a, profile_b + expectedOrder := []string{DefaultProfileName, "profile_a", "profile_b"} + names := r.ProfileNames() + for i, name := range expectedOrder { + if names[i] != name { + t.Errorf("expected name at index %d to be %s, got %s", i, name, names[i]) + } + } + + // Test modulo selection + // Account ID 0 % 3 = 0 -> claude_cli_v2 + // Account ID 1 % 3 = 1 -> profile_a + // Account ID 2 % 3 = 2 -> profile_b + // Account ID 3 % 3 = 0 -> claude_cli_v2 + testCases := []struct { + accountID int64 + expectedName string + }{ + {0, "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)"}, + {1, "Profile A"}, + {2, "Profile B"}, + {3, "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)"}, + {4, "Profile A"}, + {5, "Profile B"}, + {100, "Profile A"}, // 100 % 3 = 1 + {-1, "Profile A"}, // |-1| % 3 = 1 + {-3, "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)"}, // |-3| % 3 = 0 + } + + for _, tc := range testCases { + profile := r.GetProfileByAccountID(tc.accountID) + if profile == nil { + t.Errorf("expected profile for account %d, got nil", tc.accountID) + continue + } + if profile.Name != tc.expectedName { + t.Errorf("account %d: expected profile name '%s', got '%s'", tc.accountID, tc.expectedName, profile.Name) + } + } +} + +func TestNewRegistryFromConfig(t *testing.T) { + // Test with nil config + r := NewRegistryFromConfig(nil) + if r.ProfileCount() != 1 { + t.Errorf("expected 1 profile with nil config, got %d", r.ProfileCount()) + } + + // Test with disabled config + disabledCfg := &config.TLSFingerprintConfig{ + Enabled: false, + } + r = NewRegistryFromConfig(disabledCfg) + if r.ProfileCount() != 1 { + t.Errorf("expected 1 profile with disabled config, got %d", r.ProfileCount()) + } + + // Test with enabled config and custom profiles + enabledCfg := &config.TLSFingerprintConfig{ + Enabled: true, + Profiles: map[string]config.TLSProfileConfig{ + "custom1": { + Name: "Custom Profile 1", + EnableGREASE: true, + }, + "custom2": { + Name: "Custom Profile 2", + EnableGREASE: false, + }, + }, + } + r = NewRegistryFromConfig(enabledCfg) + + // Should have 3 profiles: default + 2 custom + if r.ProfileCount() != 3 { + t.Errorf("expected 3 profiles, got %d", r.ProfileCount()) + } + + // Check custom profiles exist + custom1 := r.GetProfile("custom1") + if custom1 == nil || custom1.Name != "Custom Profile 1" { + t.Error("expected custom1 profile to exist with correct name") + } + custom2 := r.GetProfile("custom2") + if custom2 == nil || custom2.Name != "Custom Profile 2" { + t.Error("expected custom2 profile to exist with correct name") + } +} + +func TestProfileNames(t *testing.T) { + r := NewRegistry() + + // Add profiles in non-alphabetical order + r.RegisterProfile("zebra", &Profile{Name: "Zebra"}) + r.RegisterProfile("alpha", &Profile{Name: "Alpha"}) + r.RegisterProfile("beta", &Profile{Name: "Beta"}) + + names := r.ProfileNames() + + // Should be sorted alphabetically + expected := []string{"alpha", "beta", DefaultProfileName, "zebra"} + if len(names) != len(expected) { + t.Errorf("expected %d names, got %d", len(expected), len(names)) + } + for i, name := range expected { + if names[i] != name { + t.Errorf("expected name at index %d to be %s, got %s", i, name, names[i]) + } + } + + // Test that returned slice is a copy (modifying it shouldn't affect registry) + names[0] = "modified" + originalNames := r.ProfileNames() + if originalNames[0] == "modified" { + t.Error("modifying returned slice should not affect registry") + } +} + +func TestConcurrentAccess(t *testing.T) { + r := NewRegistry() + + // Run concurrent reads and writes + done := make(chan bool) + + // Writers + for i := 0; i < 10; i++ { + go func(id int) { + for j := 0; j < 100; j++ { + r.RegisterProfile("concurrent"+string(rune('0'+id)), &Profile{Name: "Concurrent"}) + } + done <- true + }(i) + } + + // Readers + for i := 0; i < 10; i++ { + go func(id int) { + for j := 0; j < 100; j++ { + _ = r.ProfileCount() + _ = r.ProfileNames() + _ = r.GetProfileByAccountID(int64(id * j)) + _ = r.GetProfile(DefaultProfileName) + } + done <- true + }(i) + } + + // Wait for all goroutines + for i := 0; i < 20; i++ { + <-done + } + + // Test should pass without data races (run with -race flag) +} diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index feb32541..f1a95daf 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "io" + "log" "net" "net/http" "net/url" @@ -14,10 +15,19 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" + "github.com/gin-gonic/gin" ) +// debugLog prints log only in non-release mode. +func debugLog(format string, v ...any) { + if gin.Mode() != gin.ReleaseMode { + log.Printf(format, v...) + } +} + // 默认配置常量 // 这些值在配置文件未指定时作为回退默认值使用 const ( @@ -150,6 +160,170 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i return resp, nil } +// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求 +// 根据 enableTLSFingerprint 参数决定是否使用 TLS 指纹 +// +// 参数: +// - req: HTTP 请求对象 +// - proxyURL: 代理地址,空字符串表示直连 +// - accountID: 账户 ID,用于账户级隔离和 TLS 指纹模板选择 +// - accountConcurrency: 账户并发限制,用于动态调整连接池大小 +// - enableTLSFingerprint: 是否启用 TLS 指纹伪装 +// +// TLS 指纹说明: +// - 当 enableTLSFingerprint=true 时,使用 utls 库模拟 Claude CLI 的 TLS 指纹 +// - 指纹模板根据 accountID % len(profiles) 自动选择 +// - 支持直连、HTTP/HTTPS 代理、SOCKS5 代理三种场景 +func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) { + // 如果未启用 TLS 指纹,直接使用标准请求路径 + if !enableTLSFingerprint { + return s.Do(req, proxyURL, accountID, accountConcurrency) + } + + // TLS 指纹已启用,记录调试日志 + targetHost := "" + if req != nil && req.URL != nil { + targetHost = req.URL.Host + } + proxyInfo := "direct" + if proxyURL != "" { + proxyInfo = proxyURL + } + debugLog("[TLS Fingerprint] Account %d: TLS fingerprint ENABLED, target=%s, proxy=%s", accountID, targetHost, proxyInfo) + + if err := s.validateRequestHost(req); err != nil { + return nil, err + } + + // 获取 TLS 指纹 Profile + registry := tlsfingerprint.GlobalRegistry() + profile := registry.GetProfileByAccountID(accountID) + if profile == nil { + // 如果获取不到 profile,回退到普通请求 + debugLog("[TLS Fingerprint] Account %d: WARNING - no profile found, falling back to standard request", accountID) + return s.Do(req, proxyURL, accountID, accountConcurrency) + } + + debugLog("[TLS Fingerprint] Account %d: Using profile '%s' (GREASE=%v)", accountID, profile.Name, profile.EnableGREASE) + + // 获取或创建带 TLS 指纹的客户端 + entry, err := s.acquireClientWithTLS(proxyURL, accountID, accountConcurrency, profile) + if err != nil { + debugLog("[TLS Fingerprint] Account %d: Failed to acquire TLS client: %v", accountID, err) + return nil, err + } + + // 执行请求 + resp, err := entry.client.Do(req) + if err != nil { + // 请求失败,立即减少计数 + atomic.AddInt64(&entry.inFlight, -1) + atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano()) + debugLog("[TLS Fingerprint] Account %d: Request FAILED: %v", accountID, err) + return nil, err + } + + debugLog("[TLS Fingerprint] Account %d: Request SUCCESS, status=%d", accountID, resp.StatusCode) + + // 包装响应体,在关闭时自动减少计数并更新时间戳 + resp.Body = wrapTrackedBody(resp.Body, func() { + atomic.AddInt64(&entry.inFlight, -1) + atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano()) + }) + + return resp, nil +} + +// acquireClientWithTLS 获取或创建带 TLS 指纹的客户端 +func (s *httpUpstreamService) acquireClientWithTLS(proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*upstreamClientEntry, error) { + return s.getClientEntryWithTLS(proxyURL, accountID, accountConcurrency, profile, true, true) +} + +// getClientEntryWithTLS 获取或创建带 TLS 指纹的客户端条目 +// TLS 指纹客户端使用独立的缓存键,与普通客户端隔离 +func (s *httpUpstreamService) getClientEntryWithTLS(proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile, markInFlight bool, enforceLimit bool) (*upstreamClientEntry, error) { + isolation := s.getIsolationMode() + proxyKey, parsedProxy := normalizeProxyURL(proxyURL) + // TLS 指纹客户端使用独立的缓存键,加 "tls:" 前缀 + cacheKey := "tls:" + buildCacheKey(isolation, proxyKey, accountID) + poolKey := s.buildPoolKey(isolation, accountConcurrency) + ":tls" + + now := time.Now() + nowUnix := now.UnixNano() + + // 读锁快速路径 + s.mu.RLock() + if entry, ok := s.clients[cacheKey]; ok && s.shouldReuseEntry(entry, isolation, proxyKey, poolKey) { + atomic.StoreInt64(&entry.lastUsed, nowUnix) + if markInFlight { + atomic.AddInt64(&entry.inFlight, 1) + } + s.mu.RUnlock() + debugLog("[TLS Fingerprint] Account %d: Reusing existing TLS client (cacheKey=%s)", accountID, cacheKey) + return entry, nil + } + s.mu.RUnlock() + + // 写锁慢路径 + s.mu.Lock() + if entry, ok := s.clients[cacheKey]; ok { + if s.shouldReuseEntry(entry, isolation, proxyKey, poolKey) { + atomic.StoreInt64(&entry.lastUsed, nowUnix) + if markInFlight { + atomic.AddInt64(&entry.inFlight, 1) + } + s.mu.Unlock() + debugLog("[TLS Fingerprint] Account %d: Reusing existing TLS client (cacheKey=%s)", accountID, cacheKey) + return entry, nil + } + debugLog("[TLS Fingerprint] Account %d: Evicting stale TLS client (cacheKey=%s, proxyChanged=%v, poolChanged=%v)", + accountID, cacheKey, entry.proxyKey != proxyKey, entry.poolKey != poolKey) + s.removeClientLocked(cacheKey, entry) + } + + // 超出缓存上限时尝试淘汰 + if enforceLimit && s.maxUpstreamClients() > 0 { + s.evictIdleLocked(now) + if len(s.clients) >= s.maxUpstreamClients() { + if !s.evictOldestIdleLocked() { + s.mu.Unlock() + return nil, errUpstreamClientLimitReached + } + } + } + + // 创建带 TLS 指纹的 Transport + debugLog("[TLS Fingerprint] Account %d: Creating NEW TLS fingerprint client (cacheKey=%s, proxy=%s)", + accountID, cacheKey, proxyKey) + settings := s.resolvePoolSettings(isolation, accountConcurrency) + transport, err := buildUpstreamTransportWithTLSFingerprint(settings, parsedProxy, profile) + if err != nil { + s.mu.Unlock() + return nil, fmt.Errorf("build TLS fingerprint transport: %w", err) + } + + client := &http.Client{Transport: transport} + if s.shouldValidateResolvedIP() { + client.CheckRedirect = s.redirectChecker + } + + entry := &upstreamClientEntry{ + client: client, + proxyKey: proxyKey, + poolKey: poolKey, + } + atomic.StoreInt64(&entry.lastUsed, nowUnix) + if markInFlight { + atomic.StoreInt64(&entry.inFlight, 1) + } + s.clients[cacheKey] = entry + + s.evictIdleLocked(now) + s.evictOverLimitLocked() + s.mu.Unlock() + return entry, nil +} + func (s *httpUpstreamService) shouldValidateResolvedIP() bool { if s.cfg == nil { return false @@ -618,6 +792,64 @@ func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL) (*http.Tra return transport, nil } +// buildUpstreamTransportWithTLSFingerprint 构建带 TLS 指纹伪装的 Transport +// 使用 utls 库模拟 Claude CLI 的 TLS 指纹 +// +// 参数: +// - settings: 连接池配置 +// - proxyURL: 代理 URL(nil 表示直连) +// - profile: TLS 指纹配置 +// +// 返回: +// - *http.Transport: 配置好的 Transport 实例 +// - error: 配置错误 +// +// 代理类型处理: +// - nil/空: 直连,使用 TLSFingerprintDialer +// - http/https: HTTP 代理,使用 HTTPProxyDialer(CONNECT 隧道 + utls 握手) +// - socks5: SOCKS5 代理,使用 SOCKS5ProxyDialer(SOCKS5 隧道 + utls 握手) +func buildUpstreamTransportWithTLSFingerprint(settings poolSettings, proxyURL *url.URL, profile *tlsfingerprint.Profile) (*http.Transport, error) { + transport := &http.Transport{ + MaxIdleConns: settings.maxIdleConns, + MaxIdleConnsPerHost: settings.maxIdleConnsPerHost, + MaxConnsPerHost: settings.maxConnsPerHost, + IdleConnTimeout: settings.idleConnTimeout, + ResponseHeaderTimeout: settings.responseHeaderTimeout, + // 禁用默认的 TLS,我们使用自定义的 DialTLSContext + ForceAttemptHTTP2: false, + } + + // 根据代理类型选择合适的 TLS 指纹 Dialer + if proxyURL == nil { + // 直连:使用 TLSFingerprintDialer + debugLog("[TLS Fingerprint Transport] Using DIRECT TLS dialer (no proxy)") + dialer := tlsfingerprint.NewDialer(profile, nil) + transport.DialTLSContext = dialer.DialTLSContext + } else { + scheme := strings.ToLower(proxyURL.Scheme) + switch scheme { + case "socks5", "socks5h": + // SOCKS5 代理:使用 SOCKS5ProxyDialer + debugLog("[TLS Fingerprint Transport] Using SOCKS5 TLS dialer (proxy=%s)", proxyURL.Host) + socks5Dialer := tlsfingerprint.NewSOCKS5ProxyDialer(profile, proxyURL) + transport.DialTLSContext = socks5Dialer.DialTLSContext + case "http", "https": + // HTTP/HTTPS 代理:使用 HTTPProxyDialer(CONNECT 隧道) + debugLog("[TLS Fingerprint Transport] Using HTTP CONNECT TLS dialer (proxy=%s)", proxyURL.Host) + httpDialer := tlsfingerprint.NewHTTPProxyDialer(profile, proxyURL) + transport.DialTLSContext = httpDialer.DialTLSContext + default: + // 未知代理类型,回退到普通代理配置(无 TLS 指纹) + debugLog("[TLS Fingerprint Transport] WARNING: Unknown proxy scheme '%s', falling back to standard proxy (NO TLS fingerprint)", scheme) + if err := proxyutil.ConfigureTransportProxy(transport, proxyURL); err != nil { + return nil, err + } + } + } + + return transport, nil +} + // trackedBody 带跟踪功能的响应体包装器 // 在 Close 时执行回调,用于更新请求计数 type trackedBody struct { diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 36ba0bcc..16c6bb45 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -576,6 +576,25 @@ func (a *Account) IsAnthropicOAuthOrSetupToken() bool { return a.Platform == PlatformAnthropic && (a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken) } +// IsTLSFingerprintEnabled 检查是否启用 TLS 指纹伪装 +// 仅适用于 Anthropic OAuth/SetupToken 类型账号 +// 启用后将模拟 Claude Code (Node.js) 客户端的 TLS 握手特征 +func (a *Account) IsTLSFingerprintEnabled() bool { + // 仅支持 Anthropic OAuth/SetupToken 账号 + if !a.IsAnthropicOAuthOrSetupToken() { + return false + } + if a.Extra == nil { + return false + } + if v, ok := a.Extra["enable_tls_fingerprint"]; ok { + if enabled, ok := v.(bool); ok { + return enabled + } + } + return false +} + // GetWindowCostLimit 获取 5h 窗口费用阈值(美元) // 返回 0 表示未启用 func (a *Account) GetWindowCostLimit() float64 { diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 8419c2b4..46376c69 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -265,7 +265,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled()) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) } @@ -375,7 +375,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled()) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) } @@ -446,7 +446,7 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled()) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 36011947..d23a263f 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -44,6 +44,13 @@ func (s *GatewayService) debugModelRoutingEnabled() bool { return v == "1" || v == "true" || v == "yes" || v == "on" } +// debugLog prints log only in non-release mode. +func debugLog(format string, v ...any) { + if gin.Mode() != gin.ReleaseMode { + log.Printf(format, v...) + } +} + func shortSessionHash(sessionHash string) string { if sessionHash == "" { return "" @@ -412,6 +419,14 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context // SelectAccountWithLoadAwareness selects account with load-awareness and wait plan. // metadataUserID: 已废弃参数,会话限制现在统一使用 sessionHash func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, metadataUserID string) (*AccountSelectionResult, error) { + // 调试日志:记录调度入口参数 + excludedIDsList := make([]int64, 0, len(excludedIDs)) + for id := range excludedIDs { + excludedIDsList = append(excludedIDsList, id) + } + debugLog("[AccountScheduling] Starting account selection: groupID=%v model=%s session=%s excludedIDs=%v", + derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), excludedIDsList) + cfg := s.schedulingConfig() var stickyAccountID int64 @@ -454,9 +469,9 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if err == nil && result.Acquired { // 获取槽位后检查会话限制(使用 sessionHash 作为会话标识符) if !s.checkAndRegisterSession(ctx, account, sessionHash) { - result.ReleaseFunc() // 释放槽位 + result.ReleaseFunc() // 释放槽位 localExcluded[account.ID] = struct{}{} // 排除此账号 - continue // 重新选择 + continue // 重新选择 } return &AccountSelectionResult{ Account: account, @@ -1087,7 +1102,16 @@ func (s *GatewayService) resolvePlatform(ctx context.Context, groupID *int64, gr func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *int64, platform string, hasForcePlatform bool) ([]Account, bool, error) { if s.schedulerSnapshot != nil { - return s.schedulerSnapshot.ListSchedulableAccounts(ctx, groupID, platform, hasForcePlatform) + accounts, useMixed, err := s.schedulerSnapshot.ListSchedulableAccounts(ctx, groupID, platform, hasForcePlatform) + if err == nil { + debugLog("[AccountScheduling] listSchedulableAccounts (snapshot): groupID=%v platform=%s useMixed=%v count=%d", + derefGroupID(groupID), platform, useMixed, len(accounts)) + for _, acc := range accounts { + debugLog("[AccountScheduling] - Account ID=%d Name=%s Platform=%s Type=%s Status=%s TLSFingerprint=%v", + acc.ID, acc.Name, acc.Platform, acc.Type, acc.Status, acc.IsTLSFingerprintEnabled()) + } + } + return accounts, useMixed, err } useMixed := (platform == PlatformAnthropic || platform == PlatformGemini) && !hasForcePlatform if useMixed { @@ -1100,6 +1124,7 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i accounts, err = s.accountRepo.ListSchedulableByPlatforms(ctx, platforms) } if err != nil { + debugLog("[AccountScheduling] listSchedulableAccounts FAILED: groupID=%v platform=%s err=%v", derefGroupID(groupID), platform, err) return nil, useMixed, err } filtered := make([]Account, 0, len(accounts)) @@ -1109,6 +1134,12 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i } filtered = append(filtered, acc) } + debugLog("[AccountScheduling] listSchedulableAccounts (mixed): groupID=%v platform=%s rawCount=%d filteredCount=%d", + derefGroupID(groupID), platform, len(accounts), len(filtered)) + for _, acc := range filtered { + debugLog("[AccountScheduling] - Account ID=%d Name=%s Platform=%s Type=%s Status=%s TLSFingerprint=%v", + acc.ID, acc.Name, acc.Platform, acc.Type, acc.Status, acc.IsTLSFingerprintEnabled()) + } return filtered, useMixed, nil } @@ -1123,8 +1154,15 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform) } if err != nil { + debugLog("[AccountScheduling] listSchedulableAccounts FAILED: groupID=%v platform=%s err=%v", derefGroupID(groupID), platform, err) return nil, useMixed, err } + debugLog("[AccountScheduling] listSchedulableAccounts (single): groupID=%v platform=%s count=%d", + derefGroupID(groupID), platform, len(accounts)) + for _, acc := range accounts { + debugLog("[AccountScheduling] - Account ID=%d Name=%s Platform=%s Type=%s Status=%s TLSFingerprint=%v", + acc.ID, acc.Name, acc.Platform, acc.Type, acc.Status, acc.IsTLSFingerprintEnabled()) + } return accounts, useMixed, nil } @@ -2129,6 +2167,10 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A proxyURL = account.Proxy.URL() } + // 调试日志:记录即将转发的账号信息 + log.Printf("[Forward] Using account: ID=%d Name=%s Platform=%s Type=%s TLSFingerprint=%v Proxy=%s", + account.ID, account.Name, account.Platform, account.Type, account.IsTLSFingerprintEnabled(), proxyURL) + // 重试循环 var resp *http.Response retryStart := time.Now() @@ -2143,7 +2185,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A } // 发送请求 - resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled()) if err != nil { if resp != nil && resp.Body != nil { _ = resp.Body.Close() @@ -2217,7 +2259,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A filteredBody := FilterThinkingBlocksForRetry(body) retryReq, buildErr := s.buildUpstreamRequest(ctx, c, account, filteredBody, token, tokenType, reqModel) if buildErr == nil { - retryResp, retryErr := s.httpUpstream.Do(retryReq, proxyURL, account.ID, account.Concurrency) + retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled()) if retryErr == nil { if retryResp.StatusCode < 400 { log.Printf("Account %d: signature error retry succeeded (thinking downgraded)", account.ID) @@ -2249,7 +2291,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A filteredBody2 := FilterSignatureSensitiveBlocksForRetry(body) retryReq2, buildErr2 := s.buildUpstreamRequest(ctx, c, account, filteredBody2, token, tokenType, reqModel) if buildErr2 == nil { - retryResp2, retryErr2 := s.httpUpstream.Do(retryReq2, proxyURL, account.ID, account.Concurrency) + retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled()) if retryErr2 == nil { resp = retryResp2 break @@ -2364,6 +2406,10 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A _ = resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(respBody)) + // 调试日志:打印重试耗尽后的错误响应 + log.Printf("[Forward] Upstream error (retry exhausted, failover): Account=%d(%s) Status=%d RequestID=%s Body=%s", + account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000)) + s.handleRetryExhaustedSideEffects(ctx, resp, account) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ Platform: account.Platform, @@ -2391,6 +2437,10 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A _ = resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(respBody)) + // 调试日志:打印上游错误响应 + log.Printf("[Forward] Upstream error (failover): Account=%d(%s) Status=%d RequestID=%s Body=%s", + account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000)) + s.handleFailoverSideEffects(ctx, resp, account) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ Platform: account.Platform, @@ -2741,6 +2791,10 @@ func extractUpstreamErrorMessage(body []byte) string { func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account) (*ForwardResult, error) { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + // 调试日志:打印上游错误响应 + log.Printf("[Forward] Upstream error (non-retryable): Account=%d(%s) Status=%d RequestID=%s Body=%s", + account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(body), 1000)) + upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) @@ -3449,7 +3503,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, } // 发送请求 - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled()) if err != nil { setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "") s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed") @@ -3471,7 +3525,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, filteredBody := FilterThinkingBlocksForRetry(body) retryReq, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel) if buildErr == nil { - retryResp, retryErr := s.httpUpstream.Do(retryReq, proxyURL, account.ID, account.Concurrency) + retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled()) if retryErr == nil { resp = retryResp respBody, err = io.ReadAll(resp.Body) diff --git a/backend/internal/service/http_upstream_port.go b/backend/internal/service/http_upstream_port.go index 9357f763..0e4cfbec 100644 --- a/backend/internal/service/http_upstream_port.go +++ b/backend/internal/service/http_upstream_port.go @@ -10,6 +10,7 @@ import "net/http" // - 支持可选代理配置 // - 支持账户级连接池隔离 // - 实现类负责连接池管理和复用 +// - 支持可选的 TLS 指纹伪装 type HTTPUpstream interface { // Do 执行 HTTP 请求 // @@ -27,4 +28,28 @@ type HTTPUpstream interface { // - 调用方必须关闭 resp.Body,否则会导致连接泄漏 // - 响应体可能已被包装以跟踪请求生命周期 Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) + + // DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求 + // + // 参数: + // - req: HTTP 请求对象,由调用方构建 + // - proxyURL: 代理服务器地址,空字符串表示直连 + // - accountID: 账户 ID,用于连接池隔离和 TLS 指纹模板选择 + // - accountConcurrency: 账户并发限制,用于动态调整连接池大小 + // - enableTLSFingerprint: 是否启用 TLS 指纹伪装 + // + // 返回: + // - *http.Response: HTTP 响应,调用方必须关闭 Body + // - error: 请求错误(网络错误、超时等) + // + // TLS 指纹说明: + // - 当 enableTLSFingerprint=true 时,使用 utls 库模拟 Claude CLI 的 TLS 指纹 + // - TLS 指纹模板根据 accountID % len(profiles) 自动选择 + // - 支持直连、HTTP/HTTPS 代理、SOCKS5 代理三种场景 + // - 如果 enableTLSFingerprint=false,行为与 Do 方法相同 + // + // 注意: + // - 调用方必须关闭 resp.Body,否则会导致连接泄漏 + // - TLS 指纹客户端与普通客户端使用不同的缓存键,互不影响 + DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) } diff --git a/deploy/README.md b/deploy/README.md index f697247d..c42e7552 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -401,3 +401,58 @@ sudo systemctl status redis 2. **Database connection failed**: Check PostgreSQL is running and credentials are correct 3. **Redis connection failed**: Check Redis is running and password is correct 4. **Permission denied**: Ensure proper file ownership for binary install + +--- + +## TLS Fingerprint Configuration + +Sub2API supports TLS fingerprint simulation to make requests appear as if they come from the official Claude CLI (Node.js client). + +### Default Behavior + +- Built-in `claude_cli_v2` profile simulates Node.js 20.x + OpenSSL 3.x +- JA3 Hash: `1a28e69016765d92e3b381168d68922c` +- JA4: `t13d5911h1_a33745022dd6_1f22a2ca17c4` +- Profile selection: `accountID % profileCount` + +### Configuration + +```yaml +gateway: + tls_fingerprint: + enabled: true # Global switch + profiles: + # Simple profile (uses default cipher suites) + profile_1: + name: "Profile 1" + + # Profile with custom cipher suites (use compact array format) + profile_2: + name: "Profile 2" + cipher_suites: [4866, 4867, 4865, 49199, 49195, 49200, 49196] + curves: [29, 23, 24] + point_formats: [0] + + # Another custom profile + profile_3: + name: "Profile 3" + cipher_suites: [4865, 4866, 4867, 49199, 49200] + curves: [29, 23, 24, 25] +``` + +### Profile Fields + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Display name (required) | +| `cipher_suites` | []uint16 | Cipher suites in decimal. Empty = default | +| `curves` | []uint16 | Elliptic curves in decimal. Empty = default | +| `point_formats` | []uint8 | EC point formats. Empty = default | + +### Common Values Reference + +**Cipher Suites (TLS 1.3):** `4865` (AES_128_GCM), `4866` (AES_256_GCM), `4867` (CHACHA20) + +**Cipher Suites (TLS 1.2):** `49195`, `49196`, `49199`, `49200` (ECDHE variants) + +**Curves:** `29` (X25519), `23` (P-256), `24` (P-384), `25` (P-521) diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 9e85d1ff..676d2873 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -210,6 +210,19 @@ gateway: outbox_backlog_rebuild_rows: 10000 # 全量重建周期(秒),0 表示禁用 full_rebuild_interval_seconds: 300 + # TLS fingerprint simulation / TLS 指纹伪装 + # Default profile "claude_cli_v2" simulates Node.js 20.x + # 默认模板 "claude_cli_v2" 模拟 Node.js 20.x 指纹 + tls_fingerprint: + enabled: true + # profiles: + # profile_1: + # name: "Custom Profile 1" + # profile_2: + # name: "Custom Profile 2" + # cipher_suites: [4866, 4867, 4865, 49199, 49195, 49200, 49196] + # curves: [29, 23, 24] + # point_formats: [0] # ============================================================================= # API Key Auth Cache Configuration diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 176af368..f9c33be5 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1319,6 +1319,33 @@ + + +
+
+
+ +

+ {{ t('admin.accounts.quotaControl.tlsFingerprint.hint') }} +

+
+ +
+
@@ -1900,6 +1927,7 @@ const windowCostStickyReserve = ref(null) const sessionLimitEnabled = ref(false) const maxSessions = ref(null) const sessionIdleTimeout = ref(null) +const tlsFingerprintEnabled = ref(false) // Gemini tier selection (used as fallback when auto-detection is unavailable/fails) const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free') @@ -2285,6 +2313,7 @@ const resetForm = () => { sessionLimitEnabled.value = false maxSessions.value = null sessionIdleTimeout.value = null + tlsFingerprintEnabled.value = false tempUnschedEnabled.value = false tempUnschedRules.value = [] geminiOAuthType.value = 'code_assist' @@ -2568,6 +2597,11 @@ const handleAnthropicExchange = async (authCode: string) => { extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5 } + // Add TLS fingerprint settings + if (tlsFingerprintEnabled.value) { + extra.enable_tls_fingerprint = true + } + const credentials = { ...tokenInfo, ...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {}) @@ -2651,6 +2685,11 @@ const handleCookieAuth = async (sessionKey: string) => { extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5 } + // Add TLS fingerprint settings + if (tlsFingerprintEnabled.value) { + extra.enable_tls_fingerprint = true + } + const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name // Merge interceptWarmupRequests into credentials diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index d27364f1..94c7e115 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -732,6 +732,33 @@
+ + +
+
+
+ +

+ {{ t('admin.accounts.quotaControl.tlsFingerprint.hint') }} +

+
+ +
+
@@ -904,6 +931,7 @@ const windowCostStickyReserve = ref(null) const sessionLimitEnabled = ref(false) const maxSessions = ref(null) const sessionIdleTimeout = ref(null) +const tlsFingerprintEnabled = ref(false) // Computed: current preset mappings based on platform const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic')) @@ -1237,6 +1265,7 @@ function loadQuotaControlSettings(account: Account) { sessionLimitEnabled.value = false maxSessions.value = null sessionIdleTimeout.value = null + tlsFingerprintEnabled.value = false // Only applies to Anthropic OAuth/SetupToken accounts if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) { @@ -1255,6 +1284,11 @@ function loadQuotaControlSettings(account: Account) { maxSessions.value = account.max_sessions sessionIdleTimeout.value = account.session_idle_timeout_minutes ?? 5 } + + // Load TLS fingerprint setting + if (account.enable_tls_fingerprint === true) { + tlsFingerprintEnabled.value = true + } } function formatTempUnschedKeywords(value: unknown) { @@ -1407,6 +1441,13 @@ const handleSubmit = async () => { delete newExtra.session_idle_timeout_minutes } + // TLS fingerprint setting + if (tlsFingerprintEnabled.value) { + newExtra.enable_tls_fingerprint = true + } else { + delete newExtra.enable_tls_fingerprint + } + updatePayload.extra = newExtra } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 573e6a52..fe95f6b9 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1285,6 +1285,10 @@ export default { idleTimeout: 'Idle Timeout', idleTimeoutPlaceholder: '5', idleTimeoutHint: 'Sessions will be released after idle timeout' + }, + tlsFingerprint: { + label: 'TLS Fingerprint Simulation', + hint: 'Simulate Node.js/Claude Code client TLS fingerprint' } }, expired: 'Expired', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 26c485e7..ef5ebaff 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1417,6 +1417,10 @@ export default { idleTimeout: '空闲超时', idleTimeoutPlaceholder: '5', idleTimeoutHint: '会话空闲超时后自动释放' + }, + tlsFingerprint: { + label: 'TLS 指纹模拟', + hint: '模拟 Node.js/Claude Code 客户端的 TLS 指纹' } }, expired: '已过期', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6fa57c26..1e0ecf97 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -480,6 +480,9 @@ export interface Account { max_sessions?: number | null session_idle_timeout_minutes?: number | null + // TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效) + enable_tls_fingerprint?: boolean | null + // 运行时状态(仅当启用对应限制时返回) current_window_cost?: number | null // 当前窗口费用 active_sessions?: number | null // 当前活跃会话数 diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index a1731cfb..82ae3f9f 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -21,5 +21,6 @@ "types": ["vite/client"] }, "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "exclude": ["src/**/__tests__/**", "src/**/*.spec.ts", "src/**/*.test.ts"], "references": [{ "path": "./tsconfig.node.json" }] }