feat(tls): 新增 TLS 指纹模拟功能
This commit is contained in:
@@ -259,6 +259,33 @@ type GatewayConfig struct {
|
|||||||
|
|
||||||
// Scheduling: 账号调度相关配置
|
// Scheduling: 账号调度相关配置
|
||||||
Scheduling GatewaySchedulingConfig `mapstructure:"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.
|
// GatewaySchedulingConfig accounts scheduling configuration.
|
||||||
@@ -787,6 +814,8 @@ func setDefaults() {
|
|||||||
viper.SetDefault("gateway.scheduling.outbox_lag_rebuild_failures", 3)
|
viper.SetDefault("gateway.scheduling.outbox_lag_rebuild_failures", 3)
|
||||||
viper.SetDefault("gateway.scheduling.outbox_backlog_rebuild_rows", 10000)
|
viper.SetDefault("gateway.scheduling.outbox_backlog_rebuild_rows", 10000)
|
||||||
viper.SetDefault("gateway.scheduling.full_rebuild_interval_seconds", 300)
|
viper.SetDefault("gateway.scheduling.full_rebuild_interval_seconds", 300)
|
||||||
|
// TLS指纹伪装配置(默认关闭,需要账号级别单独启用)
|
||||||
|
viper.SetDefault("gateway.tls_fingerprint.enabled", true)
|
||||||
viper.SetDefault("concurrency.ping_interval", 10)
|
viper.SetDefault("concurrency.ping_interval", 10)
|
||||||
|
|
||||||
// TokenRefresh
|
// TokenRefresh
|
||||||
|
|||||||
@@ -161,6 +161,11 @@ func AccountFromServiceShallow(a *service.Account) *Account {
|
|||||||
if idleTimeout := a.GetSessionIdleTimeoutMinutes(); idleTimeout > 0 {
|
if idleTimeout := a.GetSessionIdleTimeoutMinutes(); idleTimeout > 0 {
|
||||||
out.SessionIdleTimeoutMin = &idleTimeout
|
out.SessionIdleTimeoutMin = &idleTimeout
|
||||||
}
|
}
|
||||||
|
// TLS指纹伪装开关
|
||||||
|
if a.IsTLSFingerprintEnabled() {
|
||||||
|
enabled := true
|
||||||
|
out.EnableTLSFingerprint = &enabled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ type Account struct {
|
|||||||
MaxSessions *int `json:"max_sessions,omitempty"`
|
MaxSessions *int `json:"max_sessions,omitempty"`
|
||||||
SessionIdleTimeoutMin *int `json:"session_idle_timeout_minutes,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"`
|
Proxy *Proxy `json:"proxy,omitempty"`
|
||||||
AccountGroups []AccountGroup `json:"account_groups,omitempty"`
|
AccountGroups []AccountGroup `json:"account_groups,omitempty"`
|
||||||
|
|
||||||
|
|||||||
564
backend/internal/pkg/tlsfingerprint/dialer.go
Normal file
564
backend/internal/pkg/tlsfingerprint/dialer.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
307
backend/internal/pkg/tlsfingerprint/dialer_test.go
Normal file
307
backend/internal/pkg/tlsfingerprint/dialer_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
170
backend/internal/pkg/tlsfingerprint/registry.go
Normal file
170
backend/internal/pkg/tlsfingerprint/registry.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
243
backend/internal/pkg/tlsfingerprint/registry_test.go
Normal file
243
backend/internal/pkg/tlsfingerprint/registry_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -14,10 +15,19 @@ import (
|
|||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
|
"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/service"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
"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 (
|
const (
|
||||||
@@ -150,6 +160,170 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
|
|||||||
return resp, nil
|
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 {
|
func (s *httpUpstreamService) shouldValidateResolvedIP() bool {
|
||||||
if s.cfg == nil {
|
if s.cfg == nil {
|
||||||
return false
|
return false
|
||||||
@@ -618,6 +792,64 @@ func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL) (*http.Tra
|
|||||||
return transport, nil
|
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 带跟踪功能的响应体包装器
|
// trackedBody 带跟踪功能的响应体包装器
|
||||||
// 在 Close 时执行回调,用于更新请求计数
|
// 在 Close 时执行回调,用于更新请求计数
|
||||||
type trackedBody struct {
|
type trackedBody struct {
|
||||||
|
|||||||
@@ -576,6 +576,25 @@ func (a *Account) IsAnthropicOAuthOrSetupToken() bool {
|
|||||||
return a.Platform == PlatformAnthropic && (a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken)
|
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 窗口费用阈值(美元)
|
// GetWindowCostLimit 获取 5h 窗口费用阈值(美元)
|
||||||
// 返回 0 表示未启用
|
// 返回 0 表示未启用
|
||||||
func (a *Account) GetWindowCostLimit() float64 {
|
func (a *Account) GetWindowCostLimit() float64 {
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
|
|||||||
proxyURL = account.Proxy.URL()
|
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 {
|
if err != nil {
|
||||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
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()
|
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 {
|
if err != nil {
|
||||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
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()
|
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 {
|
if err != nil {
|
||||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ func (s *GatewayService) debugModelRoutingEnabled() bool {
|
|||||||
return v == "1" || v == "true" || v == "yes" || v == "on"
|
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 {
|
func shortSessionHash(sessionHash string) string {
|
||||||
if sessionHash == "" {
|
if sessionHash == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -412,6 +419,14 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
|
|||||||
// SelectAccountWithLoadAwareness selects account with load-awareness and wait plan.
|
// SelectAccountWithLoadAwareness selects account with load-awareness and wait plan.
|
||||||
// metadataUserID: 已废弃参数,会话限制现在统一使用 sessionHash
|
// metadataUserID: 已废弃参数,会话限制现在统一使用 sessionHash
|
||||||
func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, metadataUserID string) (*AccountSelectionResult, error) {
|
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()
|
cfg := s.schedulingConfig()
|
||||||
|
|
||||||
var stickyAccountID int64
|
var stickyAccountID int64
|
||||||
@@ -454,9 +469,9 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
if err == nil && result.Acquired {
|
if err == nil && result.Acquired {
|
||||||
// 获取槽位后检查会话限制(使用 sessionHash 作为会话标识符)
|
// 获取槽位后检查会话限制(使用 sessionHash 作为会话标识符)
|
||||||
if !s.checkAndRegisterSession(ctx, account, sessionHash) {
|
if !s.checkAndRegisterSession(ctx, account, sessionHash) {
|
||||||
result.ReleaseFunc() // 释放槽位
|
result.ReleaseFunc() // 释放槽位
|
||||||
localExcluded[account.ID] = struct{}{} // 排除此账号
|
localExcluded[account.ID] = struct{}{} // 排除此账号
|
||||||
continue // 重新选择
|
continue // 重新选择
|
||||||
}
|
}
|
||||||
return &AccountSelectionResult{
|
return &AccountSelectionResult{
|
||||||
Account: account,
|
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) {
|
func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *int64, platform string, hasForcePlatform bool) ([]Account, bool, error) {
|
||||||
if s.schedulerSnapshot != nil {
|
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
|
useMixed := (platform == PlatformAnthropic || platform == PlatformGemini) && !hasForcePlatform
|
||||||
if useMixed {
|
if useMixed {
|
||||||
@@ -1100,6 +1124,7 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i
|
|||||||
accounts, err = s.accountRepo.ListSchedulableByPlatforms(ctx, platforms)
|
accounts, err = s.accountRepo.ListSchedulableByPlatforms(ctx, platforms)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
debugLog("[AccountScheduling] listSchedulableAccounts FAILED: groupID=%v platform=%s err=%v", derefGroupID(groupID), platform, err)
|
||||||
return nil, useMixed, err
|
return nil, useMixed, err
|
||||||
}
|
}
|
||||||
filtered := make([]Account, 0, len(accounts))
|
filtered := make([]Account, 0, len(accounts))
|
||||||
@@ -1109,6 +1134,12 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i
|
|||||||
}
|
}
|
||||||
filtered = append(filtered, acc)
|
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
|
return filtered, useMixed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1123,8 +1154,15 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i
|
|||||||
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
|
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
debugLog("[AccountScheduling] listSchedulableAccounts FAILED: groupID=%v platform=%s err=%v", derefGroupID(groupID), platform, err)
|
||||||
return nil, useMixed, 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
|
return accounts, useMixed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2129,6 +2167,10 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
|||||||
proxyURL = account.Proxy.URL()
|
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
|
var resp *http.Response
|
||||||
retryStart := time.Now()
|
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 err != nil {
|
||||||
if resp != nil && resp.Body != nil {
|
if resp != nil && resp.Body != nil {
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
@@ -2217,7 +2259,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
|||||||
filteredBody := FilterThinkingBlocksForRetry(body)
|
filteredBody := FilterThinkingBlocksForRetry(body)
|
||||||
retryReq, buildErr := s.buildUpstreamRequest(ctx, c, account, filteredBody, token, tokenType, reqModel)
|
retryReq, buildErr := s.buildUpstreamRequest(ctx, c, account, filteredBody, token, tokenType, reqModel)
|
||||||
if buildErr == nil {
|
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 retryErr == nil {
|
||||||
if retryResp.StatusCode < 400 {
|
if retryResp.StatusCode < 400 {
|
||||||
log.Printf("Account %d: signature error retry succeeded (thinking downgraded)", account.ID)
|
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)
|
filteredBody2 := FilterSignatureSensitiveBlocksForRetry(body)
|
||||||
retryReq2, buildErr2 := s.buildUpstreamRequest(ctx, c, account, filteredBody2, token, tokenType, reqModel)
|
retryReq2, buildErr2 := s.buildUpstreamRequest(ctx, c, account, filteredBody2, token, tokenType, reqModel)
|
||||||
if buildErr2 == nil {
|
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 {
|
if retryErr2 == nil {
|
||||||
resp = retryResp2
|
resp = retryResp2
|
||||||
break
|
break
|
||||||
@@ -2364,6 +2406,10 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
|||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
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)
|
s.handleRetryExhaustedSideEffects(ctx, resp, account)
|
||||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||||
Platform: account.Platform,
|
Platform: account.Platform,
|
||||||
@@ -2391,6 +2437,10 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
|||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
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)
|
s.handleFailoverSideEffects(ctx, resp, account)
|
||||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||||
Platform: account.Platform,
|
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) {
|
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))
|
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 := strings.TrimSpace(extractUpstreamErrorMessage(body))
|
||||||
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
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 {
|
if err != nil {
|
||||||
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "")
|
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "")
|
||||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed")
|
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)
|
filteredBody := FilterThinkingBlocksForRetry(body)
|
||||||
retryReq, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel)
|
retryReq, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel)
|
||||||
if buildErr == nil {
|
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 retryErr == nil {
|
||||||
resp = retryResp
|
resp = retryResp
|
||||||
respBody, err = io.ReadAll(resp.Body)
|
respBody, err = io.ReadAll(resp.Body)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import "net/http"
|
|||||||
// - 支持可选代理配置
|
// - 支持可选代理配置
|
||||||
// - 支持账户级连接池隔离
|
// - 支持账户级连接池隔离
|
||||||
// - 实现类负责连接池管理和复用
|
// - 实现类负责连接池管理和复用
|
||||||
|
// - 支持可选的 TLS 指纹伪装
|
||||||
type HTTPUpstream interface {
|
type HTTPUpstream interface {
|
||||||
// Do 执行 HTTP 请求
|
// Do 执行 HTTP 请求
|
||||||
//
|
//
|
||||||
@@ -27,4 +28,28 @@ type HTTPUpstream interface {
|
|||||||
// - 调用方必须关闭 resp.Body,否则会导致连接泄漏
|
// - 调用方必须关闭 resp.Body,否则会导致连接泄漏
|
||||||
// - 响应体可能已被包装以跟踪请求生命周期
|
// - 响应体可能已被包装以跟踪请求生命周期
|
||||||
Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,3 +401,58 @@ sudo systemctl status redis
|
|||||||
2. **Database connection failed**: Check PostgreSQL is running and credentials are correct
|
2. **Database connection failed**: Check PostgreSQL is running and credentials are correct
|
||||||
3. **Redis connection failed**: Check Redis is running and password is correct
|
3. **Redis connection failed**: Check Redis is running and password is correct
|
||||||
4. **Permission denied**: Ensure proper file ownership for binary install
|
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)
|
||||||
|
|||||||
@@ -210,6 +210,19 @@ gateway:
|
|||||||
outbox_backlog_rebuild_rows: 10000
|
outbox_backlog_rebuild_rows: 10000
|
||||||
# 全量重建周期(秒),0 表示禁用
|
# 全量重建周期(秒),0 表示禁用
|
||||||
full_rebuild_interval_seconds: 300
|
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
|
# API Key Auth Cache Configuration
|
||||||
|
|||||||
@@ -1319,6 +1319,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- TLS Fingerprint -->
|
||||||
|
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.tlsFingerprint.label') }}</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.accounts.quotaControl.tlsFingerprint.hint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="tlsFingerprintEnabled = !tlsFingerprintEnabled"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||||
|
tlsFingerprintEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||||
|
tlsFingerprintEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -1900,6 +1927,7 @@ const windowCostStickyReserve = ref<number | null>(null)
|
|||||||
const sessionLimitEnabled = ref(false)
|
const sessionLimitEnabled = ref(false)
|
||||||
const maxSessions = ref<number | null>(null)
|
const maxSessions = ref<number | null>(null)
|
||||||
const sessionIdleTimeout = ref<number | null>(null)
|
const sessionIdleTimeout = ref<number | null>(null)
|
||||||
|
const tlsFingerprintEnabled = ref(false)
|
||||||
|
|
||||||
// Gemini tier selection (used as fallback when auto-detection is unavailable/fails)
|
// 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')
|
const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free')
|
||||||
@@ -2285,6 +2313,7 @@ const resetForm = () => {
|
|||||||
sessionLimitEnabled.value = false
|
sessionLimitEnabled.value = false
|
||||||
maxSessions.value = null
|
maxSessions.value = null
|
||||||
sessionIdleTimeout.value = null
|
sessionIdleTimeout.value = null
|
||||||
|
tlsFingerprintEnabled.value = false
|
||||||
tempUnschedEnabled.value = false
|
tempUnschedEnabled.value = false
|
||||||
tempUnschedRules.value = []
|
tempUnschedRules.value = []
|
||||||
geminiOAuthType.value = 'code_assist'
|
geminiOAuthType.value = 'code_assist'
|
||||||
@@ -2568,6 +2597,11 @@ const handleAnthropicExchange = async (authCode: string) => {
|
|||||||
extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
|
extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add TLS fingerprint settings
|
||||||
|
if (tlsFingerprintEnabled.value) {
|
||||||
|
extra.enable_tls_fingerprint = true
|
||||||
|
}
|
||||||
|
|
||||||
const credentials = {
|
const credentials = {
|
||||||
...tokenInfo,
|
...tokenInfo,
|
||||||
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
|
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
|
||||||
@@ -2651,6 +2685,11 @@ const handleCookieAuth = async (sessionKey: string) => {
|
|||||||
extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
|
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
|
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
|
||||||
|
|
||||||
// Merge interceptWarmupRequests into credentials
|
// Merge interceptWarmupRequests into credentials
|
||||||
|
|||||||
@@ -732,6 +732,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- TLS Fingerprint -->
|
||||||
|
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.tlsFingerprint.label') }}</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.accounts.quotaControl.tlsFingerprint.hint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="tlsFingerprintEnabled = !tlsFingerprintEnabled"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||||
|
tlsFingerprintEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||||
|
tlsFingerprintEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||||
@@ -904,6 +931,7 @@ const windowCostStickyReserve = ref<number | null>(null)
|
|||||||
const sessionLimitEnabled = ref(false)
|
const sessionLimitEnabled = ref(false)
|
||||||
const maxSessions = ref<number | null>(null)
|
const maxSessions = ref<number | null>(null)
|
||||||
const sessionIdleTimeout = ref<number | null>(null)
|
const sessionIdleTimeout = ref<number | null>(null)
|
||||||
|
const tlsFingerprintEnabled = ref(false)
|
||||||
|
|
||||||
// Computed: current preset mappings based on platform
|
// Computed: current preset mappings based on platform
|
||||||
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
|
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
|
||||||
@@ -1237,6 +1265,7 @@ function loadQuotaControlSettings(account: Account) {
|
|||||||
sessionLimitEnabled.value = false
|
sessionLimitEnabled.value = false
|
||||||
maxSessions.value = null
|
maxSessions.value = null
|
||||||
sessionIdleTimeout.value = null
|
sessionIdleTimeout.value = null
|
||||||
|
tlsFingerprintEnabled.value = false
|
||||||
|
|
||||||
// Only applies to Anthropic OAuth/SetupToken accounts
|
// Only applies to Anthropic OAuth/SetupToken accounts
|
||||||
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) {
|
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) {
|
||||||
@@ -1255,6 +1284,11 @@ function loadQuotaControlSettings(account: Account) {
|
|||||||
maxSessions.value = account.max_sessions
|
maxSessions.value = account.max_sessions
|
||||||
sessionIdleTimeout.value = account.session_idle_timeout_minutes ?? 5
|
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) {
|
function formatTempUnschedKeywords(value: unknown) {
|
||||||
@@ -1407,6 +1441,13 @@ const handleSubmit = async () => {
|
|||||||
delete newExtra.session_idle_timeout_minutes
|
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
|
updatePayload.extra = newExtra
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1285,6 +1285,10 @@ export default {
|
|||||||
idleTimeout: 'Idle Timeout',
|
idleTimeout: 'Idle Timeout',
|
||||||
idleTimeoutPlaceholder: '5',
|
idleTimeoutPlaceholder: '5',
|
||||||
idleTimeoutHint: 'Sessions will be released after idle timeout'
|
idleTimeoutHint: 'Sessions will be released after idle timeout'
|
||||||
|
},
|
||||||
|
tlsFingerprint: {
|
||||||
|
label: 'TLS Fingerprint Simulation',
|
||||||
|
hint: 'Simulate Node.js/Claude Code client TLS fingerprint'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
expired: 'Expired',
|
expired: 'Expired',
|
||||||
|
|||||||
@@ -1417,6 +1417,10 @@ export default {
|
|||||||
idleTimeout: '空闲超时',
|
idleTimeout: '空闲超时',
|
||||||
idleTimeoutPlaceholder: '5',
|
idleTimeoutPlaceholder: '5',
|
||||||
idleTimeoutHint: '会话空闲超时后自动释放'
|
idleTimeoutHint: '会话空闲超时后自动释放'
|
||||||
|
},
|
||||||
|
tlsFingerprint: {
|
||||||
|
label: 'TLS 指纹模拟',
|
||||||
|
hint: '模拟 Node.js/Claude Code 客户端的 TLS 指纹'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
expired: '已过期',
|
expired: '已过期',
|
||||||
|
|||||||
@@ -480,6 +480,9 @@ export interface Account {
|
|||||||
max_sessions?: number | null
|
max_sessions?: number | null
|
||||||
session_idle_timeout_minutes?: number | null
|
session_idle_timeout_minutes?: number | null
|
||||||
|
|
||||||
|
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||||
|
enable_tls_fingerprint?: boolean | null
|
||||||
|
|
||||||
// 运行时状态(仅当启用对应限制时返回)
|
// 运行时状态(仅当启用对应限制时返回)
|
||||||
current_window_cost?: number | null // 当前窗口费用
|
current_window_cost?: number | null // 当前窗口费用
|
||||||
active_sessions?: number | null // 当前活跃会话数
|
active_sessions?: number | null // 当前活跃会话数
|
||||||
|
|||||||
@@ -21,5 +21,6 @@
|
|||||||
"types": ["vite/client"]
|
"types": ["vite/client"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/**", "src/**/*.spec.ts", "src/**/*.test.ts"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user