Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
9abda1bc
Commit
9abda1bc
authored
Jan 18, 2026
by
shaw
Browse files
feat(tls): 新增 TLS 指纹模拟功能
parent
a07174c1
Changes
20
Show whitespace changes
Inline
Side-by-side
backend/internal/config/config.go
View file @
9abda1bc
...
...
@@ -259,6 +259,33 @@ type GatewayConfig struct {
// Scheduling: 账号调度相关配置
Scheduling
GatewaySchedulingConfig
`mapstructure:"scheduling"`
// TLSFingerprint: TLS指纹伪装配置
TLSFingerprint
TLSFingerprintConfig
`mapstructure:"tls_fingerprint"`
}
// TLSFingerprintConfig TLS指纹伪装配置
// 用于模拟 Claude CLI (Node.js) 的 TLS 握手特征,避免被识别为非官方客户端
type
TLSFingerprintConfig
struct
{
// Enabled: 是否全局启用TLS指纹功能
Enabled
bool
`mapstructure:"enabled"`
// Profiles: 预定义的TLS指纹配置模板
// key 为模板名称,如 "claude_cli_v2", "chrome_120" 等
Profiles
map
[
string
]
TLSProfileConfig
`mapstructure:"profiles"`
}
// TLSProfileConfig 单个TLS指纹模板的配置
type
TLSProfileConfig
struct
{
// Name: 模板显示名称
Name
string
`mapstructure:"name"`
// EnableGREASE: 是否启用GREASE扩展(Chrome使用,Node.js不使用)
EnableGREASE
bool
`mapstructure:"enable_grease"`
// CipherSuites: TLS加密套件列表(空则使用内置默认值)
CipherSuites
[]
uint16
`mapstructure:"cipher_suites"`
// Curves: 椭圆曲线列表(空则使用内置默认值)
Curves
[]
uint16
`mapstructure:"curves"`
// PointFormats: 点格式列表(空则使用内置默认值)
PointFormats
[]
uint8
`mapstructure:"point_formats"`
}
// GatewaySchedulingConfig accounts scheduling configuration.
...
...
@@ -787,6 +814,8 @@ func setDefaults() {
viper
.
SetDefault
(
"gateway.scheduling.outbox_lag_rebuild_failures"
,
3
)
viper
.
SetDefault
(
"gateway.scheduling.outbox_backlog_rebuild_rows"
,
10000
)
viper
.
SetDefault
(
"gateway.scheduling.full_rebuild_interval_seconds"
,
300
)
// TLS指纹伪装配置(默认关闭,需要账号级别单独启用)
viper
.
SetDefault
(
"gateway.tls_fingerprint.enabled"
,
true
)
viper
.
SetDefault
(
"concurrency.ping_interval"
,
10
)
// TokenRefresh
...
...
backend/internal/handler/dto/mappers.go
View file @
9abda1bc
...
...
@@ -161,6 +161,11 @@ func AccountFromServiceShallow(a *service.Account) *Account {
if
idleTimeout
:=
a
.
GetSessionIdleTimeoutMinutes
();
idleTimeout
>
0
{
out
.
SessionIdleTimeoutMin
=
&
idleTimeout
}
// TLS指纹伪装开关
if
a
.
IsTLSFingerprintEnabled
()
{
enabled
:=
true
out
.
EnableTLSFingerprint
=
&
enabled
}
}
return
out
...
...
backend/internal/handler/dto/types.go
View file @
9abda1bc
...
...
@@ -112,6 +112,10 @@ type Account struct {
MaxSessions
*
int
`json:"max_sessions,omitempty"`
SessionIdleTimeoutMin
*
int
`json:"session_idle_timeout_minutes,omitempty"`
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
// 从 extra 字段提取,方便前端显示和编辑
EnableTLSFingerprint
*
bool
`json:"enable_tls_fingerprint,omitempty"`
Proxy
*
Proxy
`json:"proxy,omitempty"`
AccountGroups
[]
AccountGroup
`json:"account_groups,omitempty"`
...
...
backend/internal/pkg/tlsfingerprint/dialer.go
0 → 100644
View file @
9abda1bc
// 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
,
}
}
backend/internal/pkg/tlsfingerprint/dialer_test.go
0 → 100644
View file @
9abda1bc
// 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
}
backend/internal/pkg/tlsfingerprint/registry.go
0 → 100644
View file @
9abda1bc
// 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
}
backend/internal/pkg/tlsfingerprint/registry_test.go
0 → 100644
View file @
9abda1bc
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)
}
backend/internal/repository/http_upstream.go
View file @
9abda1bc
...
...
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
...
...
@@ -14,10 +15,19 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"github.com/gin-gonic/gin"
)
// debugLog prints log only in non-release mode.
func
debugLog
(
format
string
,
v
...
any
)
{
if
gin
.
Mode
()
!=
gin
.
ReleaseMode
{
log
.
Printf
(
format
,
v
...
)
}
}
// 默认配置常量
// 这些值在配置文件未指定时作为回退默认值使用
const
(
...
...
@@ -150,6 +160,170 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
return
resp
,
nil
}
// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求
// 根据 enableTLSFingerprint 参数决定是否使用 TLS 指纹
//
// 参数:
// - req: HTTP 请求对象
// - proxyURL: 代理地址,空字符串表示直连
// - accountID: 账户 ID,用于账户级隔离和 TLS 指纹模板选择
// - accountConcurrency: 账户并发限制,用于动态调整连接池大小
// - enableTLSFingerprint: 是否启用 TLS 指纹伪装
//
// TLS 指纹说明:
// - 当 enableTLSFingerprint=true 时,使用 utls 库模拟 Claude CLI 的 TLS 指纹
// - 指纹模板根据 accountID % len(profiles) 自动选择
// - 支持直连、HTTP/HTTPS 代理、SOCKS5 代理三种场景
func
(
s
*
httpUpstreamService
)
DoWithTLS
(
req
*
http
.
Request
,
proxyURL
string
,
accountID
int64
,
accountConcurrency
int
,
enableTLSFingerprint
bool
)
(
*
http
.
Response
,
error
)
{
// 如果未启用 TLS 指纹,直接使用标准请求路径
if
!
enableTLSFingerprint
{
return
s
.
Do
(
req
,
proxyURL
,
accountID
,
accountConcurrency
)
}
// TLS 指纹已启用,记录调试日志
targetHost
:=
""
if
req
!=
nil
&&
req
.
URL
!=
nil
{
targetHost
=
req
.
URL
.
Host
}
proxyInfo
:=
"direct"
if
proxyURL
!=
""
{
proxyInfo
=
proxyURL
}
debugLog
(
"[TLS Fingerprint] Account %d: TLS fingerprint ENABLED, target=%s, proxy=%s"
,
accountID
,
targetHost
,
proxyInfo
)
if
err
:=
s
.
validateRequestHost
(
req
);
err
!=
nil
{
return
nil
,
err
}
// 获取 TLS 指纹 Profile
registry
:=
tlsfingerprint
.
GlobalRegistry
()
profile
:=
registry
.
GetProfileByAccountID
(
accountID
)
if
profile
==
nil
{
// 如果获取不到 profile,回退到普通请求
debugLog
(
"[TLS Fingerprint] Account %d: WARNING - no profile found, falling back to standard request"
,
accountID
)
return
s
.
Do
(
req
,
proxyURL
,
accountID
,
accountConcurrency
)
}
debugLog
(
"[TLS Fingerprint] Account %d: Using profile '%s' (GREASE=%v)"
,
accountID
,
profile
.
Name
,
profile
.
EnableGREASE
)
// 获取或创建带 TLS 指纹的客户端
entry
,
err
:=
s
.
acquireClientWithTLS
(
proxyURL
,
accountID
,
accountConcurrency
,
profile
)
if
err
!=
nil
{
debugLog
(
"[TLS Fingerprint] Account %d: Failed to acquire TLS client: %v"
,
accountID
,
err
)
return
nil
,
err
}
// 执行请求
resp
,
err
:=
entry
.
client
.
Do
(
req
)
if
err
!=
nil
{
// 请求失败,立即减少计数
atomic
.
AddInt64
(
&
entry
.
inFlight
,
-
1
)
atomic
.
StoreInt64
(
&
entry
.
lastUsed
,
time
.
Now
()
.
UnixNano
())
debugLog
(
"[TLS Fingerprint] Account %d: Request FAILED: %v"
,
accountID
,
err
)
return
nil
,
err
}
debugLog
(
"[TLS Fingerprint] Account %d: Request SUCCESS, status=%d"
,
accountID
,
resp
.
StatusCode
)
// 包装响应体,在关闭时自动减少计数并更新时间戳
resp
.
Body
=
wrapTrackedBody
(
resp
.
Body
,
func
()
{
atomic
.
AddInt64
(
&
entry
.
inFlight
,
-
1
)
atomic
.
StoreInt64
(
&
entry
.
lastUsed
,
time
.
Now
()
.
UnixNano
())
})
return
resp
,
nil
}
// acquireClientWithTLS 获取或创建带 TLS 指纹的客户端
func
(
s
*
httpUpstreamService
)
acquireClientWithTLS
(
proxyURL
string
,
accountID
int64
,
accountConcurrency
int
,
profile
*
tlsfingerprint
.
Profile
)
(
*
upstreamClientEntry
,
error
)
{
return
s
.
getClientEntryWithTLS
(
proxyURL
,
accountID
,
accountConcurrency
,
profile
,
true
,
true
)
}
// getClientEntryWithTLS 获取或创建带 TLS 指纹的客户端条目
// TLS 指纹客户端使用独立的缓存键,与普通客户端隔离
func
(
s
*
httpUpstreamService
)
getClientEntryWithTLS
(
proxyURL
string
,
accountID
int64
,
accountConcurrency
int
,
profile
*
tlsfingerprint
.
Profile
,
markInFlight
bool
,
enforceLimit
bool
)
(
*
upstreamClientEntry
,
error
)
{
isolation
:=
s
.
getIsolationMode
()
proxyKey
,
parsedProxy
:=
normalizeProxyURL
(
proxyURL
)
// TLS 指纹客户端使用独立的缓存键,加 "tls:" 前缀
cacheKey
:=
"tls:"
+
buildCacheKey
(
isolation
,
proxyKey
,
accountID
)
poolKey
:=
s
.
buildPoolKey
(
isolation
,
accountConcurrency
)
+
":tls"
now
:=
time
.
Now
()
nowUnix
:=
now
.
UnixNano
()
// 读锁快速路径
s
.
mu
.
RLock
()
if
entry
,
ok
:=
s
.
clients
[
cacheKey
];
ok
&&
s
.
shouldReuseEntry
(
entry
,
isolation
,
proxyKey
,
poolKey
)
{
atomic
.
StoreInt64
(
&
entry
.
lastUsed
,
nowUnix
)
if
markInFlight
{
atomic
.
AddInt64
(
&
entry
.
inFlight
,
1
)
}
s
.
mu
.
RUnlock
()
debugLog
(
"[TLS Fingerprint] Account %d: Reusing existing TLS client (cacheKey=%s)"
,
accountID
,
cacheKey
)
return
entry
,
nil
}
s
.
mu
.
RUnlock
()
// 写锁慢路径
s
.
mu
.
Lock
()
if
entry
,
ok
:=
s
.
clients
[
cacheKey
];
ok
{
if
s
.
shouldReuseEntry
(
entry
,
isolation
,
proxyKey
,
poolKey
)
{
atomic
.
StoreInt64
(
&
entry
.
lastUsed
,
nowUnix
)
if
markInFlight
{
atomic
.
AddInt64
(
&
entry
.
inFlight
,
1
)
}
s
.
mu
.
Unlock
()
debugLog
(
"[TLS Fingerprint] Account %d: Reusing existing TLS client (cacheKey=%s)"
,
accountID
,
cacheKey
)
return
entry
,
nil
}
debugLog
(
"[TLS Fingerprint] Account %d: Evicting stale TLS client (cacheKey=%s, proxyChanged=%v, poolChanged=%v)"
,
accountID
,
cacheKey
,
entry
.
proxyKey
!=
proxyKey
,
entry
.
poolKey
!=
poolKey
)
s
.
removeClientLocked
(
cacheKey
,
entry
)
}
// 超出缓存上限时尝试淘汰
if
enforceLimit
&&
s
.
maxUpstreamClients
()
>
0
{
s
.
evictIdleLocked
(
now
)
if
len
(
s
.
clients
)
>=
s
.
maxUpstreamClients
()
{
if
!
s
.
evictOldestIdleLocked
()
{
s
.
mu
.
Unlock
()
return
nil
,
errUpstreamClientLimitReached
}
}
}
// 创建带 TLS 指纹的 Transport
debugLog
(
"[TLS Fingerprint] Account %d: Creating NEW TLS fingerprint client (cacheKey=%s, proxy=%s)"
,
accountID
,
cacheKey
,
proxyKey
)
settings
:=
s
.
resolvePoolSettings
(
isolation
,
accountConcurrency
)
transport
,
err
:=
buildUpstreamTransportWithTLSFingerprint
(
settings
,
parsedProxy
,
profile
)
if
err
!=
nil
{
s
.
mu
.
Unlock
()
return
nil
,
fmt
.
Errorf
(
"build TLS fingerprint transport: %w"
,
err
)
}
client
:=
&
http
.
Client
{
Transport
:
transport
}
if
s
.
shouldValidateResolvedIP
()
{
client
.
CheckRedirect
=
s
.
redirectChecker
}
entry
:=
&
upstreamClientEntry
{
client
:
client
,
proxyKey
:
proxyKey
,
poolKey
:
poolKey
,
}
atomic
.
StoreInt64
(
&
entry
.
lastUsed
,
nowUnix
)
if
markInFlight
{
atomic
.
StoreInt64
(
&
entry
.
inFlight
,
1
)
}
s
.
clients
[
cacheKey
]
=
entry
s
.
evictIdleLocked
(
now
)
s
.
evictOverLimitLocked
()
s
.
mu
.
Unlock
()
return
entry
,
nil
}
func
(
s
*
httpUpstreamService
)
shouldValidateResolvedIP
()
bool
{
if
s
.
cfg
==
nil
{
return
false
...
...
@@ -618,6 +792,64 @@ func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL) (*http.Tra
return
transport
,
nil
}
// buildUpstreamTransportWithTLSFingerprint 构建带 TLS 指纹伪装的 Transport
// 使用 utls 库模拟 Claude CLI 的 TLS 指纹
//
// 参数:
// - settings: 连接池配置
// - proxyURL: 代理 URL(nil 表示直连)
// - profile: TLS 指纹配置
//
// 返回:
// - *http.Transport: 配置好的 Transport 实例
// - error: 配置错误
//
// 代理类型处理:
// - nil/空: 直连,使用 TLSFingerprintDialer
// - http/https: HTTP 代理,使用 HTTPProxyDialer(CONNECT 隧道 + utls 握手)
// - socks5: SOCKS5 代理,使用 SOCKS5ProxyDialer(SOCKS5 隧道 + utls 握手)
func
buildUpstreamTransportWithTLSFingerprint
(
settings
poolSettings
,
proxyURL
*
url
.
URL
,
profile
*
tlsfingerprint
.
Profile
)
(
*
http
.
Transport
,
error
)
{
transport
:=
&
http
.
Transport
{
MaxIdleConns
:
settings
.
maxIdleConns
,
MaxIdleConnsPerHost
:
settings
.
maxIdleConnsPerHost
,
MaxConnsPerHost
:
settings
.
maxConnsPerHost
,
IdleConnTimeout
:
settings
.
idleConnTimeout
,
ResponseHeaderTimeout
:
settings
.
responseHeaderTimeout
,
// 禁用默认的 TLS,我们使用自定义的 DialTLSContext
ForceAttemptHTTP2
:
false
,
}
// 根据代理类型选择合适的 TLS 指纹 Dialer
if
proxyURL
==
nil
{
// 直连:使用 TLSFingerprintDialer
debugLog
(
"[TLS Fingerprint Transport] Using DIRECT TLS dialer (no proxy)"
)
dialer
:=
tlsfingerprint
.
NewDialer
(
profile
,
nil
)
transport
.
DialTLSContext
=
dialer
.
DialTLSContext
}
else
{
scheme
:=
strings
.
ToLower
(
proxyURL
.
Scheme
)
switch
scheme
{
case
"socks5"
,
"socks5h"
:
// SOCKS5 代理:使用 SOCKS5ProxyDialer
debugLog
(
"[TLS Fingerprint Transport] Using SOCKS5 TLS dialer (proxy=%s)"
,
proxyURL
.
Host
)
socks5Dialer
:=
tlsfingerprint
.
NewSOCKS5ProxyDialer
(
profile
,
proxyURL
)
transport
.
DialTLSContext
=
socks5Dialer
.
DialTLSContext
case
"http"
,
"https"
:
// HTTP/HTTPS 代理:使用 HTTPProxyDialer(CONNECT 隧道)
debugLog
(
"[TLS Fingerprint Transport] Using HTTP CONNECT TLS dialer (proxy=%s)"
,
proxyURL
.
Host
)
httpDialer
:=
tlsfingerprint
.
NewHTTPProxyDialer
(
profile
,
proxyURL
)
transport
.
DialTLSContext
=
httpDialer
.
DialTLSContext
default
:
// 未知代理类型,回退到普通代理配置(无 TLS 指纹)
debugLog
(
"[TLS Fingerprint Transport] WARNING: Unknown proxy scheme '%s', falling back to standard proxy (NO TLS fingerprint)"
,
scheme
)
if
err
:=
proxyutil
.
ConfigureTransportProxy
(
transport
,
proxyURL
);
err
!=
nil
{
return
nil
,
err
}
}
}
return
transport
,
nil
}
// trackedBody 带跟踪功能的响应体包装器
// 在 Close 时执行回调,用于更新请求计数
type
trackedBody
struct
{
...
...
backend/internal/service/account.go
View file @
9abda1bc
...
...
@@ -576,6 +576,25 @@ func (a *Account) IsAnthropicOAuthOrSetupToken() bool {
return
a
.
Platform
==
PlatformAnthropic
&&
(
a
.
Type
==
AccountTypeOAuth
||
a
.
Type
==
AccountTypeSetupToken
)
}
// IsTLSFingerprintEnabled 检查是否启用 TLS 指纹伪装
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
// 启用后将模拟 Claude Code (Node.js) 客户端的 TLS 握手特征
func
(
a
*
Account
)
IsTLSFingerprintEnabled
()
bool
{
// 仅支持 Anthropic OAuth/SetupToken 账号
if
!
a
.
IsAnthropicOAuthOrSetupToken
()
{
return
false
}
if
a
.
Extra
==
nil
{
return
false
}
if
v
,
ok
:=
a
.
Extra
[
"enable_tls_fingerprint"
];
ok
{
if
enabled
,
ok
:=
v
.
(
bool
);
ok
{
return
enabled
}
}
return
false
}
// GetWindowCostLimit 获取 5h 窗口费用阈值(美元)
// 返回 0 表示未启用
func
(
a
*
Account
)
GetWindowCostLimit
()
float64
{
...
...
backend/internal/service/account_test_service.go
View file @
9abda1bc
...
...
@@ -265,7 +265,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
proxyURL
=
account
.
Proxy
.
URL
()
}
resp
,
err
:=
s
.
httpUpstream
.
Do
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
resp
,
err
:=
s
.
httpUpstream
.
Do
WithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
account
.
IsTLSFingerprintEnabled
()
)
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Request failed: %s"
,
err
.
Error
()))
}
...
...
@@ -375,7 +375,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
proxyURL
=
account
.
Proxy
.
URL
()
}
resp
,
err
:=
s
.
httpUpstream
.
Do
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
resp
,
err
:=
s
.
httpUpstream
.
Do
WithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
account
.
IsTLSFingerprintEnabled
()
)
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Request failed: %s"
,
err
.
Error
()))
}
...
...
@@ -446,7 +446,7 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
proxyURL
=
account
.
Proxy
.
URL
()
}
resp
,
err
:=
s
.
httpUpstream
.
Do
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
resp
,
err
:=
s
.
httpUpstream
.
Do
WithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
account
.
IsTLSFingerprintEnabled
()
)
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Request failed: %s"
,
err
.
Error
()))
}
...
...
backend/internal/service/gateway_service.go
View file @
9abda1bc
...
...
@@ -44,6 +44,13 @@ func (s *GatewayService) debugModelRoutingEnabled() bool {
return
v
==
"1"
||
v
==
"true"
||
v
==
"yes"
||
v
==
"on"
}
// debugLog prints log only in non-release mode.
func
debugLog
(
format
string
,
v
...
any
)
{
if
gin
.
Mode
()
!=
gin
.
ReleaseMode
{
log
.
Printf
(
format
,
v
...
)
}
}
func
shortSessionHash
(
sessionHash
string
)
string
{
if
sessionHash
==
""
{
return
""
...
...
@@ -412,6 +419,14 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
// SelectAccountWithLoadAwareness selects account with load-awareness and wait plan.
// metadataUserID: 已废弃参数,会话限制现在统一使用 sessionHash
func
(
s
*
GatewayService
)
SelectAccountWithLoadAwareness
(
ctx
context
.
Context
,
groupID
*
int64
,
sessionHash
string
,
requestedModel
string
,
excludedIDs
map
[
int64
]
struct
{},
metadataUserID
string
)
(
*
AccountSelectionResult
,
error
)
{
// 调试日志:记录调度入口参数
excludedIDsList
:=
make
([]
int64
,
0
,
len
(
excludedIDs
))
for
id
:=
range
excludedIDs
{
excludedIDsList
=
append
(
excludedIDsList
,
id
)
}
debugLog
(
"[AccountScheduling] Starting account selection: groupID=%v model=%s session=%s excludedIDs=%v"
,
derefGroupID
(
groupID
),
requestedModel
,
shortSessionHash
(
sessionHash
),
excludedIDsList
)
cfg
:=
s
.
schedulingConfig
()
var
stickyAccountID
int64
...
...
@@ -1087,7 +1102,16 @@ func (s *GatewayService) resolvePlatform(ctx context.Context, groupID *int64, gr
func
(
s
*
GatewayService
)
listSchedulableAccounts
(
ctx
context
.
Context
,
groupID
*
int64
,
platform
string
,
hasForcePlatform
bool
)
([]
Account
,
bool
,
error
)
{
if
s
.
schedulerSnapshot
!=
nil
{
return
s
.
schedulerSnapshot
.
ListSchedulableAccounts
(
ctx
,
groupID
,
platform
,
hasForcePlatform
)
accounts
,
useMixed
,
err
:=
s
.
schedulerSnapshot
.
ListSchedulableAccounts
(
ctx
,
groupID
,
platform
,
hasForcePlatform
)
if
err
==
nil
{
debugLog
(
"[AccountScheduling] listSchedulableAccounts (snapshot): groupID=%v platform=%s useMixed=%v count=%d"
,
derefGroupID
(
groupID
),
platform
,
useMixed
,
len
(
accounts
))
for
_
,
acc
:=
range
accounts
{
debugLog
(
"[AccountScheduling] - Account ID=%d Name=%s Platform=%s Type=%s Status=%s TLSFingerprint=%v"
,
acc
.
ID
,
acc
.
Name
,
acc
.
Platform
,
acc
.
Type
,
acc
.
Status
,
acc
.
IsTLSFingerprintEnabled
())
}
}
return
accounts
,
useMixed
,
err
}
useMixed
:=
(
platform
==
PlatformAnthropic
||
platform
==
PlatformGemini
)
&&
!
hasForcePlatform
if
useMixed
{
...
...
@@ -1100,6 +1124,7 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByPlatforms
(
ctx
,
platforms
)
}
if
err
!=
nil
{
debugLog
(
"[AccountScheduling] listSchedulableAccounts FAILED: groupID=%v platform=%s err=%v"
,
derefGroupID
(
groupID
),
platform
,
err
)
return
nil
,
useMixed
,
err
}
filtered
:=
make
([]
Account
,
0
,
len
(
accounts
))
...
...
@@ -1109,6 +1134,12 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i
}
filtered
=
append
(
filtered
,
acc
)
}
debugLog
(
"[AccountScheduling] listSchedulableAccounts (mixed): groupID=%v platform=%s rawCount=%d filteredCount=%d"
,
derefGroupID
(
groupID
),
platform
,
len
(
accounts
),
len
(
filtered
))
for
_
,
acc
:=
range
filtered
{
debugLog
(
"[AccountScheduling] - Account ID=%d Name=%s Platform=%s Type=%s Status=%s TLSFingerprint=%v"
,
acc
.
ID
,
acc
.
Name
,
acc
.
Platform
,
acc
.
Type
,
acc
.
Status
,
acc
.
IsTLSFingerprintEnabled
())
}
return
filtered
,
useMixed
,
nil
}
...
...
@@ -1123,8 +1154,15 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByPlatform
(
ctx
,
platform
)
}
if
err
!=
nil
{
debugLog
(
"[AccountScheduling] listSchedulableAccounts FAILED: groupID=%v platform=%s err=%v"
,
derefGroupID
(
groupID
),
platform
,
err
)
return
nil
,
useMixed
,
err
}
debugLog
(
"[AccountScheduling] listSchedulableAccounts (single): groupID=%v platform=%s count=%d"
,
derefGroupID
(
groupID
),
platform
,
len
(
accounts
))
for
_
,
acc
:=
range
accounts
{
debugLog
(
"[AccountScheduling] - Account ID=%d Name=%s Platform=%s Type=%s Status=%s TLSFingerprint=%v"
,
acc
.
ID
,
acc
.
Name
,
acc
.
Platform
,
acc
.
Type
,
acc
.
Status
,
acc
.
IsTLSFingerprintEnabled
())
}
return
accounts
,
useMixed
,
nil
}
...
...
@@ -2129,6 +2167,10 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
proxyURL
=
account
.
Proxy
.
URL
()
}
// 调试日志:记录即将转发的账号信息
log
.
Printf
(
"[Forward] Using account: ID=%d Name=%s Platform=%s Type=%s TLSFingerprint=%v Proxy=%s"
,
account
.
ID
,
account
.
Name
,
account
.
Platform
,
account
.
Type
,
account
.
IsTLSFingerprintEnabled
(),
proxyURL
)
// 重试循环
var
resp
*
http
.
Response
retryStart
:=
time
.
Now
()
...
...
@@ -2143,7 +2185,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
// 发送请求
resp
,
err
=
s
.
httpUpstream
.
Do
(
upstreamReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
resp
,
err
=
s
.
httpUpstream
.
Do
WithTLS
(
upstreamReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
account
.
IsTLSFingerprintEnabled
()
)
if
err
!=
nil
{
if
resp
!=
nil
&&
resp
.
Body
!=
nil
{
_
=
resp
.
Body
.
Close
()
...
...
@@ -2217,7 +2259,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
filteredBody
:=
FilterThinkingBlocksForRetry
(
body
)
retryReq
,
buildErr
:=
s
.
buildUpstreamRequest
(
ctx
,
c
,
account
,
filteredBody
,
token
,
tokenType
,
reqModel
)
if
buildErr
==
nil
{
retryResp
,
retryErr
:=
s
.
httpUpstream
.
Do
(
retryReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
retryResp
,
retryErr
:=
s
.
httpUpstream
.
Do
WithTLS
(
retryReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
account
.
IsTLSFingerprintEnabled
()
)
if
retryErr
==
nil
{
if
retryResp
.
StatusCode
<
400
{
log
.
Printf
(
"Account %d: signature error retry succeeded (thinking downgraded)"
,
account
.
ID
)
...
...
@@ -2249,7 +2291,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
filteredBody2
:=
FilterSignatureSensitiveBlocksForRetry
(
body
)
retryReq2
,
buildErr2
:=
s
.
buildUpstreamRequest
(
ctx
,
c
,
account
,
filteredBody2
,
token
,
tokenType
,
reqModel
)
if
buildErr2
==
nil
{
retryResp2
,
retryErr2
:=
s
.
httpUpstream
.
Do
(
retryReq2
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
retryResp2
,
retryErr2
:=
s
.
httpUpstream
.
Do
WithTLS
(
retryReq2
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
account
.
IsTLSFingerprintEnabled
()
)
if
retryErr2
==
nil
{
resp
=
retryResp2
break
...
...
@@ -2364,6 +2406,10 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
_
=
resp
.
Body
.
Close
()
resp
.
Body
=
io
.
NopCloser
(
bytes
.
NewReader
(
respBody
))
// 调试日志:打印重试耗尽后的错误响应
log
.
Printf
(
"[Forward] Upstream error (retry exhausted, failover): Account=%d(%s) Status=%d RequestID=%s Body=%s"
,
account
.
ID
,
account
.
Name
,
resp
.
StatusCode
,
resp
.
Header
.
Get
(
"x-request-id"
),
truncateString
(
string
(
respBody
),
1000
))
s
.
handleRetryExhaustedSideEffects
(
ctx
,
resp
,
account
)
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
...
...
@@ -2391,6 +2437,10 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
_
=
resp
.
Body
.
Close
()
resp
.
Body
=
io
.
NopCloser
(
bytes
.
NewReader
(
respBody
))
// 调试日志:打印上游错误响应
log
.
Printf
(
"[Forward] Upstream error (failover): Account=%d(%s) Status=%d RequestID=%s Body=%s"
,
account
.
ID
,
account
.
Name
,
resp
.
StatusCode
,
resp
.
Header
.
Get
(
"x-request-id"
),
truncateString
(
string
(
respBody
),
1000
))
s
.
handleFailoverSideEffects
(
ctx
,
resp
,
account
)
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
...
...
@@ -2741,6 +2791,10 @@ func extractUpstreamErrorMessage(body []byte) string {
func
(
s
*
GatewayService
)
handleErrorResponse
(
ctx
context
.
Context
,
resp
*
http
.
Response
,
c
*
gin
.
Context
,
account
*
Account
)
(
*
ForwardResult
,
error
)
{
body
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
// 调试日志:打印上游错误响应
log
.
Printf
(
"[Forward] Upstream error (non-retryable): Account=%d(%s) Status=%d RequestID=%s Body=%s"
,
account
.
ID
,
account
.
Name
,
resp
.
StatusCode
,
resp
.
Header
.
Get
(
"x-request-id"
),
truncateString
(
string
(
body
),
1000
))
upstreamMsg
:=
strings
.
TrimSpace
(
extractUpstreamErrorMessage
(
body
))
upstreamMsg
=
sanitizeUpstreamErrorMessage
(
upstreamMsg
)
...
...
@@ -3449,7 +3503,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
}
// 发送请求
resp
,
err
:=
s
.
httpUpstream
.
Do
(
upstreamReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
resp
,
err
:=
s
.
httpUpstream
.
Do
WithTLS
(
upstreamReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
account
.
IsTLSFingerprintEnabled
()
)
if
err
!=
nil
{
setOpsUpstreamError
(
c
,
0
,
sanitizeUpstreamErrorMessage
(
err
.
Error
()),
""
)
s
.
countTokensError
(
c
,
http
.
StatusBadGateway
,
"upstream_error"
,
"Request failed"
)
...
...
@@ -3471,7 +3525,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
filteredBody
:=
FilterThinkingBlocksForRetry
(
body
)
retryReq
,
buildErr
:=
s
.
buildCountTokensRequest
(
ctx
,
c
,
account
,
filteredBody
,
token
,
tokenType
,
reqModel
)
if
buildErr
==
nil
{
retryResp
,
retryErr
:=
s
.
httpUpstream
.
Do
(
retryReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
retryResp
,
retryErr
:=
s
.
httpUpstream
.
Do
WithTLS
(
retryReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
account
.
IsTLSFingerprintEnabled
()
)
if
retryErr
==
nil
{
resp
=
retryResp
respBody
,
err
=
io
.
ReadAll
(
resp
.
Body
)
...
...
backend/internal/service/http_upstream_port.go
View file @
9abda1bc
...
...
@@ -10,6 +10,7 @@ import "net/http"
// - 支持可选代理配置
// - 支持账户级连接池隔离
// - 实现类负责连接池管理和复用
// - 支持可选的 TLS 指纹伪装
type
HTTPUpstream
interface
{
// Do 执行 HTTP 请求
//
...
...
@@ -27,4 +28,28 @@ type HTTPUpstream interface {
// - 调用方必须关闭 resp.Body,否则会导致连接泄漏
// - 响应体可能已被包装以跟踪请求生命周期
Do
(
req
*
http
.
Request
,
proxyURL
string
,
accountID
int64
,
accountConcurrency
int
)
(
*
http
.
Response
,
error
)
// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求
//
// 参数:
// - req: HTTP 请求对象,由调用方构建
// - proxyURL: 代理服务器地址,空字符串表示直连
// - accountID: 账户 ID,用于连接池隔离和 TLS 指纹模板选择
// - accountConcurrency: 账户并发限制,用于动态调整连接池大小
// - enableTLSFingerprint: 是否启用 TLS 指纹伪装
//
// 返回:
// - *http.Response: HTTP 响应,调用方必须关闭 Body
// - error: 请求错误(网络错误、超时等)
//
// TLS 指纹说明:
// - 当 enableTLSFingerprint=true 时,使用 utls 库模拟 Claude CLI 的 TLS 指纹
// - TLS 指纹模板根据 accountID % len(profiles) 自动选择
// - 支持直连、HTTP/HTTPS 代理、SOCKS5 代理三种场景
// - 如果 enableTLSFingerprint=false,行为与 Do 方法相同
//
// 注意:
// - 调用方必须关闭 resp.Body,否则会导致连接泄漏
// - TLS 指纹客户端与普通客户端使用不同的缓存键,互不影响
DoWithTLS
(
req
*
http
.
Request
,
proxyURL
string
,
accountID
int64
,
accountConcurrency
int
,
enableTLSFingerprint
bool
)
(
*
http
.
Response
,
error
)
}
deploy/README.md
View file @
9abda1bc
...
...
@@ -401,3 +401,58 @@ sudo systemctl status redis
2.
**Database connection failed**
: Check PostgreSQL is running and credentials are correct
3.
**Redis connection failed**
: Check Redis is running and password is correct
4.
**Permission denied**
: Ensure proper file ownership for binary install
---
## TLS Fingerprint Configuration
Sub2API supports TLS fingerprint simulation to make requests appear as if they come from the official Claude CLI (Node.js client).
### Default Behavior
-
Built-in
`claude_cli_v2`
profile simulates Node.js 20.x + OpenSSL 3.x
-
JA3 Hash:
`1a28e69016765d92e3b381168d68922c`
-
JA4:
`t13d5911h1_a33745022dd6_1f22a2ca17c4`
-
Profile selection:
`accountID % profileCount`
### Configuration
```
yaml
gateway
:
tls_fingerprint
:
enabled
:
true
# Global switch
profiles
:
# Simple profile (uses default cipher suites)
profile_1
:
name
:
"
Profile
1"
# Profile with custom cipher suites (use compact array format)
profile_2
:
name
:
"
Profile
2"
cipher_suites
:
[
4866
,
4867
,
4865
,
49199
,
49195
,
49200
,
49196
]
curves
:
[
29
,
23
,
24
]
point_formats
:
[
0
]
# Another custom profile
profile_3
:
name
:
"
Profile
3"
cipher_suites
:
[
4865
,
4866
,
4867
,
49199
,
49200
]
curves
:
[
29
,
23
,
24
,
25
]
```
### Profile Fields
| Field | Type | Description |
|-------|------|-------------|
|
`name`
| string | Display name (required) |
|
`cipher_suites`
| []uint16 | Cipher suites in decimal. Empty = default |
|
`curves`
| []uint16 | Elliptic curves in decimal. Empty = default |
|
`point_formats`
| []uint8 | EC point formats. Empty = default |
### Common Values Reference
**Cipher Suites (TLS 1.3):**
`4865`
(AES_128_GCM),
`4866`
(AES_256_GCM),
`4867`
(CHACHA20)
**Cipher Suites (TLS 1.2):**
`49195`
,
`49196`
,
`49199`
,
`49200`
(ECDHE variants)
**Curves:**
`29`
(X25519),
`23`
(P-256),
`24`
(P-384),
`25`
(P-521)
deploy/config.example.yaml
View file @
9abda1bc
...
...
@@ -210,6 +210,19 @@ gateway:
outbox_backlog_rebuild_rows
:
10000
# 全量重建周期(秒),0 表示禁用
full_rebuild_interval_seconds
:
300
# TLS fingerprint simulation / TLS 指纹伪装
# Default profile "claude_cli_v2" simulates Node.js 20.x
# 默认模板 "claude_cli_v2" 模拟 Node.js 20.x 指纹
tls_fingerprint
:
enabled
:
true
# profiles:
# profile_1:
# name: "Custom Profile 1"
# profile_2:
# name: "Custom Profile 2"
# cipher_suites: [4866, 4867, 4865, 49199, 49195, 49200, 49196]
# curves: [29, 23, 24]
# point_formats: [0]
# =============================================================================
# API Key Auth Cache Configuration
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
9abda1bc
...
...
@@ -1319,6 +1319,33 @@
<
/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
>
...
...
@@ -1900,6 +1927,7 @@ const windowCostStickyReserve = ref<number | null>(null)
const
sessionLimitEnabled
=
ref
(
false
)
const
maxSessions
=
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)
const
geminiTierGoogleOne
=
ref
<
'
google_one_free
'
|
'
google_ai_pro
'
|
'
google_ai_ultra
'
>
(
'
google_one_free
'
)
...
...
@@ -2285,6 +2313,7 @@ const resetForm = () => {
sessionLimitEnabled
.
value
=
false
maxSessions
.
value
=
null
sessionIdleTimeout
.
value
=
null
tlsFingerprintEnabled
.
value
=
false
tempUnschedEnabled
.
value
=
false
tempUnschedRules
.
value
=
[]
geminiOAuthType
.
value
=
'
code_assist
'
...
...
@@ -2568,6 +2597,11 @@ const handleAnthropicExchange = async (authCode: string) => {
extra
.
session_idle_timeout_minutes
=
sessionIdleTimeout
.
value
??
5
}
// Add TLS fingerprint settings
if
(
tlsFingerprintEnabled
.
value
)
{
extra
.
enable_tls_fingerprint
=
true
}
const
credentials
=
{
...
tokenInfo
,
...(
interceptWarmupRequests
.
value
?
{
intercept_warmup_requests
:
true
}
:
{
}
)
...
...
@@ -2651,6 +2685,11 @@ const handleCookieAuth = async (sessionKey: string) => {
extra
.
session_idle_timeout_minutes
=
sessionIdleTimeout
.
value
??
5
}
// Add TLS fingerprint settings
if
(
tlsFingerprintEnabled
.
value
)
{
extra
.
enable_tls_fingerprint
=
true
}
const
accountName
=
keys
.
length
>
1
?
`${form.name
}
#${i + 1
}
`
:
form
.
name
// Merge interceptWarmupRequests into credentials
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
9abda1bc
...
...
@@ -732,6 +732,33 @@
<
/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
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
maxSessions
=
ref
<
number
|
null
>
(
null
)
const
sessionIdleTimeout
=
ref
<
number
|
null
>
(
null
)
const
tlsFingerprintEnabled
=
ref
(
false
)
// Computed: current preset mappings based on platform
const
presetMappings
=
computed
(()
=>
getPresetMappingsByPlatform
(
props
.
account
?.
platform
||
'
anthropic
'
))
...
...
@@ -1237,6 +1265,7 @@ function loadQuotaControlSettings(account: Account) {
sessionLimitEnabled
.
value
=
false
maxSessions
.
value
=
null
sessionIdleTimeout
.
value
=
null
tlsFingerprintEnabled
.
value
=
false
// Only applies to Anthropic OAuth/SetupToken accounts
if
(
account
.
platform
!==
'
anthropic
'
||
(
account
.
type
!==
'
oauth
'
&&
account
.
type
!==
'
setup-token
'
))
{
...
...
@@ -1255,6 +1284,11 @@ function loadQuotaControlSettings(account: Account) {
maxSessions
.
value
=
account
.
max_sessions
sessionIdleTimeout
.
value
=
account
.
session_idle_timeout_minutes
??
5
}
// Load TLS fingerprint setting
if
(
account
.
enable_tls_fingerprint
===
true
)
{
tlsFingerprintEnabled
.
value
=
true
}
}
function
formatTempUnschedKeywords
(
value
:
unknown
)
{
...
...
@@ -1407,6 +1441,13 @@ const handleSubmit = async () => {
delete
newExtra
.
session_idle_timeout_minutes
}
// TLS fingerprint setting
if
(
tlsFingerprintEnabled
.
value
)
{
newExtra
.
enable_tls_fingerprint
=
true
}
else
{
delete
newExtra
.
enable_tls_fingerprint
}
updatePayload
.
extra
=
newExtra
}
...
...
frontend/src/i18n/locales/en.ts
View file @
9abda1bc
...
...
@@ -1285,6 +1285,10 @@ export default {
idleTimeout
:
'
Idle Timeout
'
,
idleTimeoutPlaceholder
:
'
5
'
,
idleTimeoutHint
:
'
Sessions will be released after idle timeout
'
},
tlsFingerprint
:
{
label
:
'
TLS Fingerprint Simulation
'
,
hint
:
'
Simulate Node.js/Claude Code client TLS fingerprint
'
}
},
expired
:
'
Expired
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
9abda1bc
...
...
@@ -1417,6 +1417,10 @@ export default {
idleTimeout
:
'
空闲超时
'
,
idleTimeoutPlaceholder
:
'
5
'
,
idleTimeoutHint
:
'
会话空闲超时后自动释放
'
},
tlsFingerprint
:
{
label
:
'
TLS 指纹模拟
'
,
hint
:
'
模拟 Node.js/Claude Code 客户端的 TLS 指纹
'
}
},
expired
:
'
已过期
'
,
...
...
frontend/src/types/index.ts
View file @
9abda1bc
...
...
@@ -480,6 +480,9 @@ export interface Account {
max_sessions
?:
number
|
null
session_idle_timeout_minutes
?:
number
|
null
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
enable_tls_fingerprint
?:
boolean
|
null
// 运行时状态(仅当启用对应限制时返回)
current_window_cost
?:
number
|
null
// 当前窗口费用
active_sessions
?:
number
|
null
// 当前活跃会话数
...
...
frontend/tsconfig.json
View file @
9abda1bc
...
...
@@ -21,5 +21,6 @@
"types"
:
[
"vite/client"
]
},
"include"
:
[
"src/**/*.ts"
,
"src/**/*.tsx"
,
"src/**/*.vue"
],
"exclude"
:
[
"src/**/__tests__/**"
,
"src/**/*.spec.ts"
,
"src/**/*.test.ts"
],
"references"
:
[{
"path"
:
"./tsconfig.node.json"
}]
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment