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
b6d46fd5
Unverified
Commit
b6d46fd5
authored
Mar 27, 2026
by
InCerryGit
Committed by
GitHub
Mar 27, 2026
Browse files
Merge branch 'Wei-Shaw:main' into main
parents
fa68cbad
fdd8499f
Changes
107
Hide whitespace changes
Inline
Side-by-side
backend/internal/pkg/tlsfingerprint/dialer.go
View file @
b6d46fd5
...
...
@@ -17,12 +17,19 @@ import (
)
// Profile contains TLS fingerprint configuration.
// All slice fields use built-in defaults when empty.
type
Profile
struct
{
Name
string
// Profile name for identification
CipherSuites
[]
uint16
Curves
[]
uint16
PointFormats
[]
uint8
EnableGREASE
bool
Name
string
// Profile name for identification
CipherSuites
[]
uint16
Curves
[]
uint16
PointFormats
[]
uint16
EnableGREASE
bool
SignatureAlgorithms
[]
uint16
// Empty uses defaultSignatureAlgorithms
ALPNProtocols
[]
string
// Empty uses ["http/1.1"]
SupportedVersions
[]
uint16
// Empty uses [TLS1.3, TLS1.2]
KeyShareGroups
[]
uint16
// Empty uses [X25519]
PSKModes
[]
uint16
// Empty uses [psk_dhe_ke]
Extensions
[]
uint16
// Extension type IDs in order; empty uses default Node.js 24.x order
}
// Dialer creates TLS connections with custom fingerprints.
...
...
@@ -45,154 +52,67 @@ type SOCKS5ProxyDialer struct {
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
// Default TLS fingerprint values captured from Claude Code (Node.js 24.x)
// Captured via tls-fingerprint-web capture server
// JA3 Hash: 44f88fca027f27bab4bb08d4af15f23e
// JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
var
(
// defaultCipherSuites contains
all 59
cipher suites from
Claude CLI
// defaultCipherSuites contains
the 17
cipher suites from
Node.js 24.x
// Order is critical for JA3 fingerprint matching
defaultCipherSuites
=
[]
uint16
{
// TLS 1.3 cipher suites (MUST be first)
// TLS 1.3 cipher suites
0x1301
,
// TLS_AES_128_GCM_SHA256
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
0xc0
30
,
// TLS_ECDHE_RSA_WITH_AES_
256
_GCM_SHA
384
0xc0
2f
,
// TLS_ECDHE_RSA_WITH_AES_
128
_GCM_SHA
256
0xc02c
,
// TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
0xc030
,
// TLS_ECDHE_RSA_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
// ECDHE + 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
// ECDHE + AES-CBC-SHA (legacy fallback)
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
0xc00a
,
// TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
0xc014
,
// TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
// RSA + AES-GCM
/CCM/ARIA (non-PFS, 128-bit
)
// RSA + AES-GCM
(non-PFS
)
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
0x009d
,
// TLS_RSA_WITH_AES_256_GCM_SHA384
// 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
// RSA + AES-CBC-SHA (non-PFS, legacy)
0x002f
,
// TLS_RSA_WITH_AES_128_CBC_SHA
// Renegotiation indication
0x00ff
,
// TLS_EMPTY_RENEGOTIATION_INFO_SCSV
0x0035
,
// TLS_RSA_WITH_AES_256_CBC_SHA
}
// defaultCurves contains the
10
supported groups from
Claude CLI (including FFDHE)
// defaultCurves contains the
3
supported groups from
Node.js 24.x
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
{
utls
.
X25519
,
// 0x001d
utls
.
CurveP256
,
// 0x0017 (secp256r1)
utls
.
CurveP384
,
// 0x0018 (secp384r1)
}
// defaultPointFormats contains point formats from Node.js 24.x
defaultPointFormats
=
[]
uint16
{
0
,
// uncompressed
1
,
// ansiX962_compressed_prime
2
,
// ansiX962_compressed_char2
}
// defaultSignatureAlgorithms contains the
20
signature algorithms from
Claude CLI
// defaultSignatureAlgorithms contains the
9
signature algorithms from
Node.js 24.x
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
0x0503
,
// ecdsa_secp384r1_sha384
0x0805
,
// rsa_pss_rsae_sha384
0x0501
,
// rsa_pkcs1_sha384
0x0806
,
// rsa_pss_rsae_sha512
0x0601
,
// rsa_pkcs1_sha512
0x0303
,
// ecdsa_sha224
0x0301
,
// rsa_pkcs1_sha224
0x0302
,
// dsa_sha224
0x0402
,
// dsa_sha256
0x0502
,
// dsa_sha384
0x0602
,
// dsa_sha512
0x0201
,
// rsa_pkcs1_sha1
}
)
...
...
@@ -256,49 +176,7 @@ func (d *SOCKS5ProxyDialer) DialTLSContext(ctx context.Context, network, addr st
slog
.
Debug
(
"tls_fingerprint_socks5_tunnel_established"
)
// Step 3: Perform TLS handshake on the tunnel with utls fingerprint
host
,
_
,
err
:=
net
.
SplitHostPort
(
addr
)
if
err
!=
nil
{
host
=
addr
}
slog
.
Debug
(
"tls_fingerprint_socks5_starting_handshake"
,
"host"
,
host
)
// Build ClientHello specification from profile (Node.js/Claude CLI fingerprint)
spec
:=
buildClientHelloSpecFromProfile
(
d
.
profile
)
slog
.
Debug
(
"tls_fingerprint_socks5_clienthello_spec"
,
"cipher_suites"
,
len
(
spec
.
CipherSuites
),
"extensions"
,
len
(
spec
.
Extensions
),
"compression_methods"
,
spec
.
CompressionMethods
,
"tls_vers_max"
,
spec
.
TLSVersMax
,
"tls_vers_min"
,
spec
.
TLSVersMin
)
if
d
.
profile
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_socks5_using_profile"
,
"name"
,
d
.
profile
.
Name
,
"grease"
,
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
{
slog
.
Debug
(
"tls_fingerprint_socks5_apply_preset_failed"
,
"error"
,
err
)
_
=
conn
.
Close
()
return
nil
,
fmt
.
Errorf
(
"apply TLS preset: %w"
,
err
)
}
if
err
:=
tlsConn
.
HandshakeContext
(
ctx
);
err
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_socks5_handshake_failed"
,
"error"
,
err
)
_
=
conn
.
Close
()
return
nil
,
fmt
.
Errorf
(
"TLS handshake failed: %w"
,
err
)
}
state
:=
tlsConn
.
ConnectionState
()
slog
.
Debug
(
"tls_fingerprint_socks5_handshake_success"
,
"version"
,
state
.
Version
,
"cipher_suite"
,
state
.
CipherSuite
,
"alpn"
,
state
.
NegotiatedProtocol
)
return
tlsConn
,
nil
return
performTLSHandshake
(
ctx
,
conn
,
d
.
profile
,
addr
)
}
// DialTLSContext establishes a TLS connection through HTTP proxy with the configured fingerprint.
...
...
@@ -358,7 +236,8 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri
slog
.
Debug
(
"tls_fingerprint_http_proxy_read_response_failed"
,
"error"
,
err
)
return
nil
,
fmt
.
Errorf
(
"read CONNECT response: %w"
,
err
)
}
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
// CONNECT response has no body; do not defer resp.Body.Close() as it wraps the
// same conn that will be used for the TLS handshake.
if
resp
.
StatusCode
!=
http
.
StatusOK
{
_
=
conn
.
Close
()
...
...
@@ -368,47 +247,7 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri
slog
.
Debug
(
"tls_fingerprint_http_proxy_tunnel_established"
)
// Step 4: Perform TLS handshake on the tunnel with utls fingerprint
host
,
_
,
err
:=
net
.
SplitHostPort
(
addr
)
if
err
!=
nil
{
host
=
addr
}
slog
.
Debug
(
"tls_fingerprint_http_proxy_starting_handshake"
,
"host"
,
host
)
// Build ClientHello specification (reuse the shared method)
spec
:=
buildClientHelloSpecFromProfile
(
d
.
profile
)
slog
.
Debug
(
"tls_fingerprint_http_proxy_clienthello_spec"
,
"cipher_suites"
,
len
(
spec
.
CipherSuites
),
"extensions"
,
len
(
spec
.
Extensions
))
if
d
.
profile
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_http_proxy_using_profile"
,
"name"
,
d
.
profile
.
Name
,
"grease"
,
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
{
slog
.
Debug
(
"tls_fingerprint_http_proxy_apply_preset_failed"
,
"error"
,
err
)
_
=
conn
.
Close
()
return
nil
,
fmt
.
Errorf
(
"apply TLS preset: %w"
,
err
)
}
if
err
:=
tlsConn
.
HandshakeContext
(
ctx
);
err
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_http_proxy_handshake_failed"
,
"error"
,
err
)
_
=
conn
.
Close
()
return
nil
,
fmt
.
Errorf
(
"TLS handshake failed: %w"
,
err
)
}
state
:=
tlsConn
.
ConnectionState
()
slog
.
Debug
(
"tls_fingerprint_http_proxy_handshake_success"
,
"version"
,
state
.
Version
,
"cipher_suite"
,
state
.
CipherSuite
,
"alpn"
,
state
.
NegotiatedProtocol
)
return
tlsConn
,
nil
return
performTLSHandshake
(
ctx
,
conn
,
d
.
profile
,
addr
)
}
// DialTLSContext establishes a TLS connection with the configured fingerprint.
...
...
@@ -423,53 +262,35 @@ func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net.
}
slog
.
Debug
(
"tls_fingerprint_tcp_connected"
,
"addr"
,
addr
)
// Extract hostname for SNI
// Perform TLS handshake with utls fingerprint
return
performTLSHandshake
(
ctx
,
conn
,
d
.
profile
,
addr
)
}
// performTLSHandshake performs the uTLS handshake on an established connection.
// It builds a ClientHello spec from the profile, applies it, and completes the handshake.
// On failure, conn is closed and an error is returned.
func
performTLSHandshake
(
ctx
context
.
Context
,
conn
net
.
Conn
,
profile
*
Profile
,
addr
string
)
(
net
.
Conn
,
error
)
{
host
,
_
,
err
:=
net
.
SplitHostPort
(
addr
)
if
err
!=
nil
{
host
=
addr
}
slog
.
Debug
(
"tls_fingerprint_sni_hostname"
,
"host"
,
host
)
// Build ClientHello specification
spec
:=
d
.
buildClientHelloSpec
()
slog
.
Debug
(
"tls_fingerprint_clienthello_spec"
,
"cipher_suites"
,
len
(
spec
.
CipherSuites
),
"extensions"
,
len
(
spec
.
Extensions
))
spec
:=
buildClientHelloSpecFromProfile
(
profile
)
tlsConn
:=
utls
.
UClient
(
conn
,
&
utls
.
Config
{
ServerName
:
host
},
utls
.
HelloCustom
)
// Log profile info
if
d
.
profile
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_using_profile"
,
"name"
,
d
.
profile
.
Name
,
"grease"
,
d
.
profile
.
EnableGREASE
)
}
else
{
slog
.
Debug
(
"tls_fingerprint_using_default_profile"
)
}
// 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
{
slog
.
Debug
(
"tls_fingerprint_apply_preset_failed"
,
"error"
,
err
)
_
=
conn
.
Close
()
return
nil
,
err
return
nil
,
fmt
.
Errorf
(
"apply TLS preset: %w"
,
err
)
}
slog
.
Debug
(
"tls_fingerprint_preset_applied"
)
// Perform TLS handshake
if
err
:=
tlsConn
.
HandshakeContext
(
ctx
);
err
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_handshake_failed"
,
"error"
,
err
,
"local_addr"
,
conn
.
LocalAddr
(),
"remote_addr"
,
conn
.
RemoteAddr
())
_
=
conn
.
Close
()
return
nil
,
fmt
.
Errorf
(
"TLS handshake failed: %w"
,
err
)
}
// Log successful handshake details
state
:=
tlsConn
.
ConnectionState
()
slog
.
Debug
(
"tls_fingerprint_handshake_success"
,
"host"
,
host
,
"version"
,
state
.
Version
,
"cipher_suite"
,
state
.
CipherSuite
,
"alpn"
,
state
.
NegotiatedProtocol
)
...
...
@@ -477,11 +298,6 @@ func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net.
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
))
...
...
@@ -491,70 +307,143 @@ func toUTLSCurves(curves []uint16) []utls.CurveID {
return
result
}
// defaultExtensionOrder is the Node.js 24.x extension order.
// Used when Profile.Extensions is empty.
var
defaultExtensionOrder
=
[]
uint16
{
0
,
// server_name
65037
,
// encrypted_client_hello
23
,
// extended_master_secret
65281
,
// renegotiation_info
10
,
// supported_groups
11
,
// ec_point_formats
35
,
// session_ticket
16
,
// alpn
5
,
// status_request
13
,
// signature_algorithms
18
,
// signed_certificate_timestamp
51
,
// key_share
45
,
// psk_key_exchange_modes
43
,
// supported_versions
}
// isGREASEValue checks if a uint16 value matches the TLS GREASE pattern (0x?a?a).
func
isGREASEValue
(
v
uint16
)
bool
{
return
v
&
0x0f0f
==
0x0a0a
&&
v
>>
8
==
v
&
0xff
}
// 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
//
Resolve effective values (profile overrides or built-in defaults)
cipherSuites
:=
defaultCipherSuites
if
profile
!=
nil
&&
len
(
profile
.
CipherSuites
)
>
0
{
cipherSuites
=
profile
.
CipherSuites
}
else
{
cipherSuites
=
defaultCipherSuites
}
// Get curves
var
curves
[]
utls
.
CurveID
curves
:=
defaultCurves
if
profile
!=
nil
&&
len
(
profile
.
Curves
)
>
0
{
curves
=
toUTLSCurves
(
profile
.
Curves
)
}
else
{
curves
=
defaultCurves
}
// Get point formats
var
pointFormats
[]
uint8
pointFormats
:=
defaultPointFormats
if
profile
!=
nil
&&
len
(
profile
.
PointFormats
)
>
0
{
pointFormats
=
profile
.
PointFormats
}
else
{
pointFormats
=
defaultPointFormats
}
// Check if GREASE is enabled
signatureAlgorithms
:=
defaultSignatureAlgorithms
if
profile
!=
nil
&&
len
(
profile
.
SignatureAlgorithms
)
>
0
{
signatureAlgorithms
=
make
([]
utls
.
SignatureScheme
,
len
(
profile
.
SignatureAlgorithms
))
for
i
,
s
:=
range
profile
.
SignatureAlgorithms
{
signatureAlgorithms
[
i
]
=
utls
.
SignatureScheme
(
s
)
}
}
alpnProtocols
:=
[]
string
{
"http/1.1"
}
if
profile
!=
nil
&&
len
(
profile
.
ALPNProtocols
)
>
0
{
alpnProtocols
=
profile
.
ALPNProtocols
}
supportedVersions
:=
[]
uint16
{
utls
.
VersionTLS13
,
utls
.
VersionTLS12
}
if
profile
!=
nil
&&
len
(
profile
.
SupportedVersions
)
>
0
{
supportedVersions
=
profile
.
SupportedVersions
}
keyShareGroups
:=
[]
utls
.
CurveID
{
utls
.
X25519
}
if
profile
!=
nil
&&
len
(
profile
.
KeyShareGroups
)
>
0
{
keyShareGroups
=
toUTLSCurves
(
profile
.
KeyShareGroups
)
}
pskModes
:=
[]
uint16
{
uint16
(
utls
.
PskModeDHE
)}
if
profile
!=
nil
&&
len
(
profile
.
PSKModes
)
>
0
{
pskModes
=
profile
.
PSKModes
}
enableGREASE
:=
profile
!=
nil
&&
profile
.
EnableGREASE
extensions
:=
make
([]
utls
.
TLSExtension
,
0
,
16
)
// Build key shares
keyShares
:=
make
([]
utls
.
KeyShare
,
len
(
keyShareGroups
))
for
i
,
g
:=
range
keyShareGroups
{
keyShares
[
i
]
=
utls
.
KeyShare
{
Group
:
g
}
}
if
enableGREASE
{
extensions
=
append
(
extensions
,
&
utls
.
UtlsGREASEExtension
{})
// Determine extension order
extOrder
:=
defaultExtensionOrder
if
profile
!=
nil
&&
len
(
profile
.
Extensions
)
>
0
{
extOrder
=
profile
.
Extensions
}
// 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
{
// Build extensions list from the ordered IDs.
// Parametric extensions (curves, sigalgs, etc.) are populated with resolved profile values.
// Unknown IDs use GenericExtension (sends type ID with empty data).
extensions
:=
make
([]
utls
.
TLSExtension
,
0
,
len
(
extOrder
)
+
2
)
for
_
,
id
:=
range
extOrder
{
if
isGREASEValue
(
id
)
{
extensions
=
append
(
extensions
,
&
utls
.
UtlsGREASEExtension
{})
continue
}
switch
id
{
case
0
:
// server_name
extensions
=
append
(
extensions
,
&
utls
.
SNIExtension
{})
case
5
:
// status_request (OCSP)
extensions
=
append
(
extensions
,
&
utls
.
StatusRequestExtension
{})
case
10
:
// supported_groups
extensions
=
append
(
extensions
,
&
utls
.
SupportedCurvesExtension
{
Curves
:
curves
})
case
11
:
// ec_point_formats
extensions
=
append
(
extensions
,
&
utls
.
SupportedPointsExtension
{
SupportedPoints
:
toUint8s
(
pointFormats
)})
case
13
:
// signature_algorithms
extensions
=
append
(
extensions
,
&
utls
.
SignatureAlgorithmsExtension
{
SupportedSignatureAlgorithms
:
signatureAlgorithms
})
case
16
:
// alpn
extensions
=
append
(
extensions
,
&
utls
.
ALPNExtension
{
AlpnProtocols
:
alpnProtocols
})
case
18
:
// signed_certificate_timestamp
extensions
=
append
(
extensions
,
&
utls
.
SCTExtension
{})
case
23
:
// extended_master_secret
extensions
=
append
(
extensions
,
&
utls
.
ExtendedMasterSecretExtension
{})
case
35
:
// session_ticket
extensions
=
append
(
extensions
,
&
utls
.
SessionTicketExtension
{})
case
43
:
// supported_versions
extensions
=
append
(
extensions
,
&
utls
.
SupportedVersionsExtension
{
Versions
:
supportedVersions
})
case
45
:
// psk_key_exchange_modes
extensions
=
append
(
extensions
,
&
utls
.
PSKKeyExchangeModesExtension
{
Modes
:
toUint8s
(
pskModes
)})
case
50
:
// signature_algorithms_cert
extensions
=
append
(
extensions
,
&
utls
.
SignatureAlgorithmsCertExtension
{
SupportedSignatureAlgorithms
:
signatureAlgorithms
})
case
51
:
// key_share
extensions
=
append
(
extensions
,
&
utls
.
KeyShareExtension
{
KeyShares
:
keyShares
})
case
0xfe0d
:
// encrypted_client_hello (ECH, 65037)
// Send GREASE ECH with random payload — mimics Node.js behavior when no real ECHConfig is available.
// An empty GenericExtension causes "error decoding message" from servers that validate ECH format.
extensions
=
append
(
extensions
,
&
utls
.
GREASEEncryptedClientHelloExtension
{})
case
0xff01
:
// renegotiation_info
extensions
=
append
(
extensions
,
&
utls
.
RenegotiationInfoExtension
{})
default
:
// Unknown extension — send as GenericExtension (type ID + empty data).
// This covers encrypt_then_mac(22) and any future extensions.
extensions
=
append
(
extensions
,
&
utls
.
GenericExtension
{
Id
:
id
})
}
}
// For default extension order with EnableGREASE, wrap with GREASE bookends
if
enableGREASE
&&
(
profile
==
nil
||
len
(
profile
.
Extensions
)
==
0
)
{
extensions
=
append
([]
utls
.
TLSExtension
{
&
utls
.
UtlsGREASEExtension
{}},
extensions
...
)
extensions
=
append
(
extensions
,
&
utls
.
UtlsGREASEExtension
{})
}
...
...
@@ -566,3 +455,12 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
TLSVersMin
:
utls
.
VersionTLS10
,
}
}
// toUint8s converts []uint16 to []uint8 (for utls fields that require []uint8).
func
toUint8s
(
vals
[]
uint16
)
[]
uint8
{
out
:=
make
([]
uint8
,
len
(
vals
))
for
i
,
v
:=
range
vals
{
out
[
i
]
=
uint8
(
v
)
}
return
out
}
backend/internal/pkg/tlsfingerprint/dialer_capture_test.go
0 → 100644
View file @
b6d46fd5
//go:build integration
package
tlsfingerprint
import
(
"context"
"encoding/json"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
utls
"github.com/refraction-networking/utls"
)
// CapturedFingerprint mirrors the Fingerprint struct from tls-fingerprint-web.
// Used to deserialize the JSON response from the capture server.
type
CapturedFingerprint
struct
{
JA3Raw
string
`json:"ja3_raw"`
JA3Hash
string
`json:"ja3_hash"`
JA4
string
`json:"ja4"`
HTTP2
string
`json:"http2"`
CipherSuites
[]
int
`json:"cipher_suites"`
Curves
[]
int
`json:"curves"`
PointFormats
[]
int
`json:"point_formats"`
Extensions
[]
int
`json:"extensions"`
SignatureAlgorithms
[]
int
`json:"signature_algorithms"`
ALPNProtocols
[]
string
`json:"alpn_protocols"`
SupportedVersions
[]
int
`json:"supported_versions"`
KeyShareGroups
[]
int
`json:"key_share_groups"`
PSKModes
[]
int
`json:"psk_modes"`
CompressCertAlgos
[]
int
`json:"compress_cert_algos"`
EnableGREASE
bool
`json:"enable_grease"`
}
// TestDialerAgainstCaptureServer connects to the tls-fingerprint-web capture server
// and verifies that the dialer's TLS fingerprint matches the configured Profile.
//
// Default capture server: https://tls.sub2api.org:8090
// Override with env: TLSFINGERPRINT_CAPTURE_URL=https://localhost:8443
//
// Run: go test -v -run TestDialerAgainstCaptureServer ./internal/pkg/tlsfingerprint/...
func
TestDialerAgainstCaptureServer
(
t
*
testing
.
T
)
{
captureURL
:=
os
.
Getenv
(
"TLSFINGERPRINT_CAPTURE_URL"
)
if
captureURL
==
""
{
captureURL
=
"https://tls.sub2api.org:8090"
}
tests
:=
[]
struct
{
name
string
profile
*
Profile
}{
{
name
:
"default_profile"
,
profile
:
&
Profile
{
Name
:
"default"
,
EnableGREASE
:
false
,
// All empty → uses built-in defaults
},
},
{
name
:
"linux_x64_node_v22171"
,
profile
:
&
Profile
{
Name
:
"linux_x64_node_v22171"
,
EnableGREASE
:
false
,
CipherSuites
:
[]
uint16
{
4866
,
4867
,
4865
,
49199
,
49195
,
49200
,
49196
,
158
,
49191
,
103
,
49192
,
107
,
163
,
159
,
52393
,
52392
,
52394
,
49327
,
49325
,
49315
,
49311
,
49245
,
49249
,
49239
,
49235
,
162
,
49326
,
49324
,
49314
,
49310
,
49244
,
49248
,
49238
,
49234
,
49188
,
106
,
49187
,
64
,
49162
,
49172
,
57
,
56
,
49161
,
49171
,
51
,
50
,
157
,
49313
,
49309
,
49233
,
156
,
49312
,
49308
,
49232
,
61
,
60
,
53
,
47
,
255
},
Curves
:
[]
uint16
{
29
,
23
,
30
,
25
,
24
,
256
,
257
,
258
,
259
,
260
},
PointFormats
:
[]
uint16
{
0
,
1
,
2
},
SignatureAlgorithms
:
[]
uint16
{
0x0403
,
0x0503
,
0x0603
,
0x0807
,
0x0808
,
0x0809
,
0x080a
,
0x080b
,
0x0804
,
0x0805
,
0x0806
,
0x0401
,
0x0501
,
0x0601
,
0x0303
,
0x0301
,
0x0302
,
0x0402
,
0x0502
,
0x0602
},
ALPNProtocols
:
[]
string
{
"http/1.1"
},
SupportedVersions
:
[]
uint16
{
0x0304
,
0x0303
},
KeyShareGroups
:
[]
uint16
{
29
},
PSKModes
:
[]
uint16
{
1
},
Extensions
:
[]
uint16
{
0
,
11
,
10
,
35
,
16
,
22
,
23
,
13
,
43
,
45
,
51
},
},
},
{
name
:
"macos_arm64_node_v2430"
,
profile
:
&
Profile
{
Name
:
"MacOS_arm64_node_v2430"
,
EnableGREASE
:
false
,
CipherSuites
:
[]
uint16
{
4865
,
4866
,
4867
,
49195
,
49199
,
49196
,
49200
,
52393
,
52392
,
49161
,
49171
,
49162
,
49172
,
156
,
157
,
47
,
53
},
Curves
:
[]
uint16
{
29
,
23
,
24
},
PointFormats
:
[]
uint16
{
0
},
SignatureAlgorithms
:
[]
uint16
{
0x0403
,
0x0804
,
0x0401
,
0x0503
,
0x0805
,
0x0501
,
0x0806
,
0x0601
,
0x0201
},
ALPNProtocols
:
[]
string
{
"http/1.1"
},
SupportedVersions
:
[]
uint16
{
0x0304
,
0x0303
},
KeyShareGroups
:
[]
uint16
{
29
},
PSKModes
:
[]
uint16
{
1
},
Extensions
:
[]
uint16
{
0
,
65037
,
23
,
65281
,
10
,
11
,
35
,
16
,
5
,
13
,
18
,
51
,
45
,
43
},
},
},
}
for
_
,
tc
:=
range
tests
{
t
.
Run
(
tc
.
name
,
func
(
t
*
testing
.
T
)
{
captured
:=
fetchCapturedFingerprint
(
t
,
captureURL
,
tc
.
profile
)
if
captured
==
nil
{
return
}
t
.
Logf
(
"JA3 Hash: %s"
,
captured
.
JA3Hash
)
t
.
Logf
(
"JA4: %s"
,
captured
.
JA4
)
// Resolve effective profile values (what the dialer actually uses)
effectiveCipherSuites
:=
tc
.
profile
.
CipherSuites
if
len
(
effectiveCipherSuites
)
==
0
{
effectiveCipherSuites
=
defaultCipherSuites
}
effectiveCurves
:=
tc
.
profile
.
Curves
if
len
(
effectiveCurves
)
==
0
{
effectiveCurves
=
make
([]
uint16
,
len
(
defaultCurves
))
for
i
,
c
:=
range
defaultCurves
{
effectiveCurves
[
i
]
=
uint16
(
c
)
}
}
effectivePointFormats
:=
tc
.
profile
.
PointFormats
if
len
(
effectivePointFormats
)
==
0
{
effectivePointFormats
=
defaultPointFormats
}
effectiveSigAlgs
:=
tc
.
profile
.
SignatureAlgorithms
if
len
(
effectiveSigAlgs
)
==
0
{
effectiveSigAlgs
=
make
([]
uint16
,
len
(
defaultSignatureAlgorithms
))
for
i
,
s
:=
range
defaultSignatureAlgorithms
{
effectiveSigAlgs
[
i
]
=
uint16
(
s
)
}
}
effectiveALPN
:=
tc
.
profile
.
ALPNProtocols
if
len
(
effectiveALPN
)
==
0
{
effectiveALPN
=
[]
string
{
"http/1.1"
}
}
effectiveVersions
:=
tc
.
profile
.
SupportedVersions
if
len
(
effectiveVersions
)
==
0
{
effectiveVersions
=
[]
uint16
{
0x0304
,
0x0303
}
}
effectiveKeyShare
:=
tc
.
profile
.
KeyShareGroups
if
len
(
effectiveKeyShare
)
==
0
{
effectiveKeyShare
=
[]
uint16
{
29
}
// X25519
}
effectivePSKModes
:=
tc
.
profile
.
PSKModes
if
len
(
effectivePSKModes
)
==
0
{
effectivePSKModes
=
[]
uint16
{
1
}
// psk_dhe_ke
}
// Verify each field
assertIntSliceEqual
(
t
,
"cipher_suites"
,
uint16sToInts
(
effectiveCipherSuites
),
captured
.
CipherSuites
)
assertIntSliceEqual
(
t
,
"curves"
,
uint16sToInts
(
effectiveCurves
),
captured
.
Curves
)
assertIntSliceEqual
(
t
,
"point_formats"
,
uint16sToInts
(
effectivePointFormats
),
captured
.
PointFormats
)
assertIntSliceEqual
(
t
,
"signature_algorithms"
,
uint16sToInts
(
effectiveSigAlgs
),
captured
.
SignatureAlgorithms
)
assertStringSliceEqual
(
t
,
"alpn_protocols"
,
effectiveALPN
,
captured
.
ALPNProtocols
)
assertIntSliceEqual
(
t
,
"supported_versions"
,
uint16sToInts
(
effectiveVersions
),
captured
.
SupportedVersions
)
assertIntSliceEqual
(
t
,
"key_share_groups"
,
uint16sToInts
(
effectiveKeyShare
),
captured
.
KeyShareGroups
)
assertIntSliceEqual
(
t
,
"psk_modes"
,
uint16sToInts
(
effectivePSKModes
),
captured
.
PSKModes
)
if
captured
.
EnableGREASE
!=
tc
.
profile
.
EnableGREASE
{
t
.
Errorf
(
"enable_grease: got %v, want %v"
,
captured
.
EnableGREASE
,
tc
.
profile
.
EnableGREASE
)
}
else
{
t
.
Logf
(
" enable_grease: %v OK"
,
captured
.
EnableGREASE
)
}
// Verify extension order
// Use profile.Extensions if set, otherwise the default order (Node.js 24.x)
expectedExtOrder
:=
uint16sToInts
(
defaultExtensionOrder
)
if
len
(
tc
.
profile
.
Extensions
)
>
0
{
expectedExtOrder
=
uint16sToInts
(
tc
.
profile
.
Extensions
)
}
// Strip GREASE values from both expected and captured for comparison
var
filteredExpected
,
filteredActual
[]
int
for
_
,
e
:=
range
expectedExtOrder
{
if
!
isGREASEValue
(
uint16
(
e
))
{
filteredExpected
=
append
(
filteredExpected
,
e
)
}
}
for
_
,
e
:=
range
captured
.
Extensions
{
if
!
isGREASEValue
(
uint16
(
e
))
{
filteredActual
=
append
(
filteredActual
,
e
)
}
}
assertIntSliceEqual
(
t
,
"extensions (order, non-GREASE)"
,
filteredExpected
,
filteredActual
)
// Print full captured data as JSON for debugging
capturedJSON
,
_
:=
json
.
MarshalIndent
(
captured
,
" "
,
" "
)
t
.
Logf
(
"Full captured fingerprint:
\n
%s"
,
string
(
capturedJSON
))
})
}
}
func
fetchCapturedFingerprint
(
t
*
testing
.
T
,
captureURL
string
,
profile
*
Profile
)
*
CapturedFingerprint
{
t
.
Helper
()
dialer
:=
NewDialer
(
profile
,
nil
)
client
:=
&
http
.
Client
{
Transport
:
&
http
.
Transport
{
DialTLSContext
:
dialer
.
DialTLSContext
,
},
Timeout
:
10
*
time
.
Second
,
}
ctx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
10
*
time
.
Second
)
defer
cancel
()
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
"POST"
,
captureURL
,
strings
.
NewReader
(
`{"model":"test"}`
))
if
err
!=
nil
{
t
.
Fatalf
(
"create request: %v"
,
err
)
return
nil
}
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
Header
.
Set
(
"Authorization"
,
"Bearer test-token"
)
resp
,
err
:=
client
.
Do
(
req
)
if
err
!=
nil
{
t
.
Fatalf
(
"request failed: %v"
,
err
)
return
nil
}
defer
resp
.
Body
.
Close
()
body
,
err
:=
io
.
ReadAll
(
resp
.
Body
)
if
err
!=
nil
{
t
.
Fatalf
(
"read body: %v"
,
err
)
return
nil
}
var
fp
CapturedFingerprint
if
err
:=
json
.
Unmarshal
(
body
,
&
fp
);
err
!=
nil
{
t
.
Logf
(
"Response body: %s"
,
string
(
body
))
t
.
Fatalf
(
"parse response: %v"
,
err
)
return
nil
}
return
&
fp
}
func
uint16sToInts
(
vals
[]
uint16
)
[]
int
{
result
:=
make
([]
int
,
len
(
vals
))
for
i
,
v
:=
range
vals
{
result
[
i
]
=
int
(
v
)
}
return
result
}
func
assertIntSliceEqual
(
t
*
testing
.
T
,
name
string
,
expected
,
actual
[]
int
)
{
t
.
Helper
()
if
len
(
expected
)
!=
len
(
actual
)
{
t
.
Errorf
(
"%s: length mismatch: got %d, want %d"
,
name
,
len
(
actual
),
len
(
expected
))
if
len
(
actual
)
<
20
&&
len
(
expected
)
<
20
{
t
.
Errorf
(
" got: %v"
,
actual
)
t
.
Errorf
(
" want: %v"
,
expected
)
}
return
}
mismatches
:=
0
for
i
:=
range
expected
{
if
expected
[
i
]
!=
actual
[
i
]
{
if
mismatches
<
5
{
t
.
Errorf
(
"%s[%d]: got %d (0x%04x), want %d (0x%04x)"
,
name
,
i
,
actual
[
i
],
actual
[
i
],
expected
[
i
],
expected
[
i
])
}
mismatches
++
}
}
if
mismatches
==
0
{
t
.
Logf
(
" %s: %d items OK"
,
name
,
len
(
expected
))
}
else
if
mismatches
>
5
{
t
.
Errorf
(
" %s: %d/%d mismatches (showing first 5)"
,
name
,
mismatches
,
len
(
expected
))
}
}
func
assertStringSliceEqual
(
t
*
testing
.
T
,
name
string
,
expected
,
actual
[]
string
)
{
t
.
Helper
()
if
len
(
expected
)
!=
len
(
actual
)
{
t
.
Errorf
(
"%s: length mismatch: got %d (%v), want %d (%v)"
,
name
,
len
(
actual
),
actual
,
len
(
expected
),
expected
)
return
}
for
i
:=
range
expected
{
if
expected
[
i
]
!=
actual
[
i
]
{
t
.
Errorf
(
"%s[%d]: got %q, want %q"
,
name
,
i
,
actual
[
i
],
expected
[
i
])
return
}
}
t
.
Logf
(
" %s: %v OK"
,
name
,
expected
)
}
// TestBuildClientHelloSpecNewFields tests that new Profile fields are correctly applied.
func
TestBuildClientHelloSpecNewFields
(
t
*
testing
.
T
)
{
// Test custom ALPN, versions, key shares, PSK modes
profile
:=
&
Profile
{
Name
:
"custom_full"
,
EnableGREASE
:
false
,
CipherSuites
:
[]
uint16
{
0x1301
,
0x1302
},
Curves
:
[]
uint16
{
29
,
23
},
PointFormats
:
[]
uint16
{
0
},
SignatureAlgorithms
:
[]
uint16
{
0x0403
,
0x0804
},
ALPNProtocols
:
[]
string
{
"h2"
,
"http/1.1"
},
SupportedVersions
:
[]
uint16
{
0x0304
},
KeyShareGroups
:
[]
uint16
{
29
,
23
},
PSKModes
:
[]
uint16
{
1
},
}
spec
:=
buildClientHelloSpecFromProfile
(
profile
)
// Verify cipher suites
if
len
(
spec
.
CipherSuites
)
!=
2
||
spec
.
CipherSuites
[
0
]
!=
0x1301
{
t
.
Errorf
(
"cipher suites: got %v"
,
spec
.
CipherSuites
)
}
// Check extensions for expected values
var
foundALPN
,
foundVersions
,
foundKeyShare
,
foundPSK
,
foundSigAlgs
bool
for
_
,
ext
:=
range
spec
.
Extensions
{
switch
e
:=
ext
.
(
type
)
{
case
*
utls
.
ALPNExtension
:
foundALPN
=
true
if
len
(
e
.
AlpnProtocols
)
!=
2
||
e
.
AlpnProtocols
[
0
]
!=
"h2"
{
t
.
Errorf
(
"ALPN: got %v, want [h2, http/1.1]"
,
e
.
AlpnProtocols
)
}
case
*
utls
.
SupportedVersionsExtension
:
foundVersions
=
true
if
len
(
e
.
Versions
)
!=
1
||
e
.
Versions
[
0
]
!=
0x0304
{
t
.
Errorf
(
"versions: got %v, want [0x0304]"
,
e
.
Versions
)
}
case
*
utls
.
KeyShareExtension
:
foundKeyShare
=
true
if
len
(
e
.
KeyShares
)
!=
2
{
t
.
Errorf
(
"key shares: got %d entries, want 2"
,
len
(
e
.
KeyShares
))
}
case
*
utls
.
PSKKeyExchangeModesExtension
:
foundPSK
=
true
if
len
(
e
.
Modes
)
!=
1
||
e
.
Modes
[
0
]
!=
1
{
t
.
Errorf
(
"PSK modes: got %v, want [1]"
,
e
.
Modes
)
}
case
*
utls
.
SignatureAlgorithmsExtension
:
foundSigAlgs
=
true
if
len
(
e
.
SupportedSignatureAlgorithms
)
!=
2
{
t
.
Errorf
(
"sig algs: got %d, want 2"
,
len
(
e
.
SupportedSignatureAlgorithms
))
}
}
}
for
name
,
found
:=
range
map
[
string
]
bool
{
"ALPN"
:
foundALPN
,
"Versions"
:
foundVersions
,
"KeyShare"
:
foundKeyShare
,
"PSK"
:
foundPSK
,
"SigAlgs"
:
foundSigAlgs
,
}
{
if
!
found
{
t
.
Errorf
(
"extension %s not found in spec"
,
name
)
}
}
// Test nil profile uses all defaults
specDefault
:=
buildClientHelloSpecFromProfile
(
nil
)
for
_
,
ext
:=
range
specDefault
.
Extensions
{
switch
e
:=
ext
.
(
type
)
{
case
*
utls
.
ALPNExtension
:
if
len
(
e
.
AlpnProtocols
)
!=
1
||
e
.
AlpnProtocols
[
0
]
!=
"http/1.1"
{
t
.
Errorf
(
"default ALPN: got %v, want [http/1.1]"
,
e
.
AlpnProtocols
)
}
case
*
utls
.
SupportedVersionsExtension
:
if
len
(
e
.
Versions
)
!=
2
{
t
.
Errorf
(
"default versions: got %v, want 2 entries"
,
e
.
Versions
)
}
case
*
utls
.
KeyShareExtension
:
if
len
(
e
.
KeyShares
)
!=
1
{
t
.
Errorf
(
"default key shares: got %d, want 1"
,
len
(
e
.
KeyShares
))
}
}
}
t
.
Log
(
"TestBuildClientHelloSpecNewFields passed"
)
}
backend/internal/pkg/tlsfingerprint/dialer_integration_test.go
View file @
b6d46fd5
...
...
@@ -40,16 +40,15 @@ func skipIfExternalServiceUnavailable(t *testing.T, err error) {
// 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 2
0
.x)
// Expected JA4: t13d
5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP)
// Expected JA3 hash:
44f88fca027f27bab4bb08d4af15f23e (
Node.js 2
4
.x)
// Expected JA4: t13d
1714h1_5b57614c22b0_7baf387fc6ff
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"
,
Name
:
"
Default Profile
Test"
,
EnableGREASE
:
false
,
}
dialer
:=
NewDialer
(
profile
,
nil
)
...
...
@@ -61,7 +60,6 @@ func TestJA3Fingerprint(t *testing.T) {
Timeout
:
30
*
time
.
Second
,
}
// Use tls.peet.ws fingerprint detection API
ctx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
30
*
time
.
Second
)
defer
cancel
()
...
...
@@ -69,7 +67,7 @@ func TestJA3Fingerprint(t *testing.T) {
if
err
!=
nil
{
t
.
Fatalf
(
"failed to create request: %v"
,
err
)
}
req
.
Header
.
Set
(
"User-Agent"
,
"Claude Code/2.0.0 Node.js/2
0.0
.0"
)
req
.
Header
.
Set
(
"User-Agent"
,
"Claude Code/2.0.0 Node.js/2
4.3
.0"
)
resp
,
err
:=
client
.
Do
(
req
)
skipIfExternalServiceUnavailable
(
t
,
err
)
...
...
@@ -86,71 +84,23 @@ func TestJA3Fingerprint(t *testing.T) {
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"
expectedJA3Hash
:=
"44f88fca027f27bab4bb08d4af15f23e"
if
fpResp
.
TLS
.
JA3Hash
==
expectedJA3Hash
{
t
.
Logf
(
"✓ JA3 hash matches
expected value
: %s"
,
expectedJA3Hash
)
t
.
Logf
(
"✓ JA3 hash matches: %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
)
expectedJA4CipherHash
:=
"_5b57614c22b0_"
if
strings
.
Contains
(
fpResp
.
TLS
.
JA4
,
expectedJA4CipherHash
)
{
t
.
Logf
(
"✓ JA4 cipher hash matches: %s"
,
expectedJA4CipherHash
)
}
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
)
}
t
.
Errorf
(
"✗ JA4 cipher hash mismatch: got %s, expected containing %s"
,
fpResp
.
TLS
.
JA4
,
expectedJA4CipherHash
)
}
// 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"
)
}
}
// TestProfileExpectation defines expected fingerprint values for a profile.
type
TestProfileExpectation
struct
{
Profile
*
Profile
ExpectedJA3
string
// Expected JA3 hash (empty = don't check)
ExpectedJA4
string
// Expected full JA4 (empty = don't check)
JA4CipherHash
string
// Expected JA4 cipher hash - the stable middle part (empty = don't check)
}
// TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws.
...
...
@@ -164,30 +114,24 @@ func TestAllProfiles(t *testing.T) {
// These profiles are from config.yaml gateway.tls_fingerprint.profiles
profiles
:=
[]
TestProfileExpectation
{
{
// Linux x64 Node.js v22.17.1
// Expected JA3 Hash: 1a28e69016765d92e3b381168d68922c
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4
// Default profile (Node.js 24.x)
Profile
:
&
Profile
{
Name
:
"
linux_x64
_node_v2
2171
"
,
Name
:
"
default
_node_v2
4
"
,
EnableGREASE
:
false
,
CipherSuites
:
[]
uint16
{
4866
,
4867
,
4865
,
49199
,
49195
,
49200
,
49196
,
158
,
49191
,
103
,
49192
,
107
,
163
,
159
,
52393
,
52392
,
52394
,
49327
,
49325
,
49315
,
49311
,
49245
,
49249
,
49239
,
49235
,
162
,
49326
,
49324
,
49314
,
49310
,
49244
,
49248
,
49238
,
49234
,
49188
,
106
,
49187
,
64
,
49162
,
49172
,
57
,
56
,
49161
,
49171
,
51
,
50
,
157
,
49313
,
49309
,
49233
,
156
,
49312
,
49308
,
49232
,
61
,
60
,
53
,
47
,
255
},
Curves
:
[]
uint16
{
29
,
23
,
30
,
25
,
24
,
256
,
257
,
258
,
259
,
260
},
PointFormats
:
[]
uint8
{
0
,
1
,
2
},
},
JA4CipherHash
:
"
a33745022dd6"
,
// stable part
JA4CipherHash
:
"
5b57614c22b0"
,
},
{
// MacOS arm64 Node.js v22.18.0
// Expected JA3 Hash: 70cb5ca646080902703ffda87036a5ea
// Expected JA4: t13d5912h1_a33745022dd6_dbd39dd1d406
// Linux x64 Node.js v22.17.1 (explicit profile with v22 extensions)
Profile
:
&
Profile
{
Name
:
"
macos_arm
64_node_v221
80
"
,
Name
:
"
linux_x
64_node_v221
71
"
,
EnableGREASE
:
false
,
CipherSuites
:
[]
uint16
{
4866
,
4867
,
4865
,
49199
,
49195
,
49200
,
49196
,
158
,
49191
,
103
,
49192
,
107
,
163
,
159
,
52393
,
52392
,
52394
,
49327
,
49325
,
49315
,
49311
,
49245
,
49249
,
49239
,
49235
,
162
,
49326
,
49324
,
49314
,
49310
,
49244
,
49248
,
49238
,
49234
,
49188
,
106
,
49187
,
64
,
49162
,
49172
,
57
,
56
,
49161
,
49171
,
51
,
50
,
157
,
49313
,
49309
,
49233
,
156
,
49312
,
49308
,
49232
,
61
,
60
,
53
,
47
,
255
},
Curves
:
[]
uint16
{
29
,
23
,
30
,
25
,
24
,
256
,
257
,
258
,
259
,
260
},
PointFormats
:
[]
uint8
{
0
,
1
,
2
},
PointFormats
:
[]
uint16
{
0
,
1
,
2
},
Extensions
:
[]
uint16
{
0
,
11
,
10
,
35
,
16
,
22
,
23
,
13
,
43
,
45
,
51
},
},
JA4CipherHash
:
"a33745022dd6"
,
// stable part (same cipher suites)
JA4CipherHash
:
"a33745022dd6"
,
},
}
...
...
backend/internal/pkg/tlsfingerprint/dialer_test.go
View file @
b6d46fd5
...
...
@@ -55,13 +55,13 @@ func TestDialerBasicConnection(t *testing.T) {
// 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 2
0
.x)
// Expected JA4: t13d
5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP)
// Expected JA3 hash:
44f88fca027f27bab4bb08d4af15f23e (
Node.js 2
4
.x)
// Expected JA4: t13d
1714h1_5b57614c22b0_7baf387fc6ff
func
TestJA3Fingerprint
(
t
*
testing
.
T
)
{
skipNetworkTest
(
t
)
profile
:=
&
Profile
{
Name
:
"
Claude CLI
Test"
,
Name
:
"
Default Profile
Test"
,
EnableGREASE
:
false
,
}
dialer
:=
NewDialer
(
profile
,
nil
)
...
...
@@ -81,7 +81,7 @@ func TestJA3Fingerprint(t *testing.T) {
if
err
!=
nil
{
t
.
Fatalf
(
"failed to create request: %v"
,
err
)
}
req
.
Header
.
Set
(
"User-Agent"
,
"Claude Code/2.0.0 Node.js/2
0.0
.0"
)
req
.
Header
.
Set
(
"User-Agent"
,
"Claude Code/2.0.0 Node.js/2
4.3
.0"
)
resp
,
err
:=
client
.
Do
(
req
)
if
err
!=
nil
{
...
...
@@ -107,34 +107,28 @@ func TestJA3Fingerprint(t *testing.T) {
t
.
Logf
(
"PeetPrint: %s"
,
fpResp
.
TLS
.
PeetPrint
)
t
.
Logf
(
"PeetPrint Hash: %s"
,
fpResp
.
TLS
.
PeetPrintHash
)
// Verify JA3 hash matches expected value
expectedJA3Hash
:=
"
1a28e69016765d92e3b381168d68922c
"
// Verify JA3 hash matches expected value
(Node.js 24.x default)
expectedJA3Hash
:=
"
44f88fca027f27bab4bb08d4af15f23e
"
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
)
// Verify JA4 cipher hash (stable middle part)
expectedJA4CipherHash
:=
"_5b57614c22b0_"
if
strings
.
Contains
(
fpResp
.
TLS
.
JA4
,
expectedJA4CipherHash
)
{
t
.
Logf
(
"✓ JA4 cipher hash matches: %s"
,
expectedJA4CipherHash
)
}
else
{
t
.
Errorf
(
"✗ JA4
suffix
mismatch: got %s, expected
suffix
%s"
,
fpResp
.
TLS
.
JA4
,
expectedJA4
Suffix
)
t
.
Errorf
(
"✗ JA4
cipher hash
mismatch: got %s, expected
containing
%s"
,
fpResp
.
TLS
.
JA4
,
expectedJA4
CipherHash
)
}
// 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"
// Verify JA4 prefix (t13d1714h1 or t13i1714h1)
expectedJA4Prefix
:=
"t13d1714h1"
if
strings
.
HasPrefix
(
fpResp
.
TLS
.
JA4
,
expectedJA4Prefix
)
{
t
.
Logf
(
"✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain,
59
=ciphers, 1
1
=extensions, h1=HTTP/1.1)"
,
expectedJA4Prefix
)
t
.
Logf
(
"✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain,
17
=ciphers, 1
4
=extensions, h1=HTTP/1.1)"
,
expectedJA4Prefix
)
}
else
{
// Also accept 'i' variant for IP connections
altPrefix
:=
"t13i5911h1"
altPrefix
:=
"t13i1714h1"
if
strings
.
HasPrefix
(
fpResp
.
TLS
.
JA4
,
altPrefix
)
{
t
.
Logf
(
"✓ JA4 prefix matches (IP variant): %s"
,
altPrefix
)
}
else
{
...
...
@@ -142,16 +136,15 @@ func TestJA3Fingerprint(t *testing.T) {
}
}
// Verify JA3 contains expected cipher suites
(TLS 1.3 ciphers at the beginning)
if
strings
.
Contains
(
fpResp
.
TLS
.
JA3
,
"4866-4867
-4865
"
)
{
// Verify JA3 contains expected
TLS 1.3
cipher suites
if
strings
.
Contains
(
fpResp
.
TLS
.
JA3
,
"
4865-
4866-4867"
)
{
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"
// Verify extension list (14 extensions, Node.js 24.x order)
expectedExtensions
:=
"0-65037-23-65281-10-11-35-16-5-13-18-51-45-43"
if
strings
.
Contains
(
fpResp
.
TLS
.
JA3
,
expectedExtensions
)
{
t
.
Logf
(
"✓ JA3 contains expected extension list: %s"
,
expectedExtensions
)
}
else
{
...
...
@@ -186,8 +179,8 @@ func TestDialerWithProfile(t *testing.T) {
// 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
(
)
spec1
:=
buildClientHelloSpec
FromProfile
(
dialer1
.
profile
)
spec2
:=
buildClientHelloSpec
FromProfile
(
dialer2
.
profile
)
// Profile with GREASE should have more extensions
if
len
(
spec2
.
Extensions
)
<=
len
(
spec1
.
Extensions
)
{
...
...
@@ -296,47 +289,33 @@ func mustParseURL(rawURL string) *url.URL {
return
u
}
// TestProfileExpectation defines expected fingerprint values for a profile.
type
TestProfileExpectation
struct
{
Profile
*
Profile
ExpectedJA3
string
// Expected JA3 hash (empty = don't check)
ExpectedJA4
string
// Expected full JA4 (empty = don't check)
JA4CipherHash
string
// Expected JA4 cipher hash - the stable middle part (empty = don't check)
}
// TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws.
// Run with: go test -v -run TestAllProfiles ./internal/pkg/tlsfingerprint/...
func
TestAllProfiles
(
t
*
testing
.
T
)
{
skipNetworkTest
(
t
)
// Define all profiles to test with their expected fingerprints
// These profiles are from config.yaml gateway.tls_fingerprint.profiles
profiles
:=
[]
TestProfileExpectation
{
{
//
Linux x64 Node.js v22.17.1
//
Expected
JA3 Hash:
1a28e69016765d92e3b381168d68922c
//
Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4
//
Default profile (Node.js 24.x)
// JA3 Hash:
44f88fca027f27bab4bb08d4af15f23e
//
JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
Profile
:
&
Profile
{
Name
:
"
linux_x64
_node_v2
2171
"
,
Name
:
"
default
_node_v2
4
"
,
EnableGREASE
:
false
,
CipherSuites
:
[]
uint16
{
4866
,
4867
,
4865
,
49199
,
49195
,
49200
,
49196
,
158
,
49191
,
103
,
49192
,
107
,
163
,
159
,
52393
,
52392
,
52394
,
49327
,
49325
,
49315
,
49311
,
49245
,
49249
,
49239
,
49235
,
162
,
49326
,
49324
,
49314
,
49310
,
49244
,
49248
,
49238
,
49234
,
49188
,
106
,
49187
,
64
,
49162
,
49172
,
57
,
56
,
49161
,
49171
,
51
,
50
,
157
,
49313
,
49309
,
49233
,
156
,
49312
,
49308
,
49232
,
61
,
60
,
53
,
47
,
255
},
Curves
:
[]
uint16
{
29
,
23
,
30
,
25
,
24
,
256
,
257
,
258
,
259
,
260
},
PointFormats
:
[]
uint8
{
0
,
1
,
2
},
},
JA4CipherHash
:
"
a33745022dd6"
,
// stable part
JA4CipherHash
:
"
5b57614c22b0"
,
},
{
// MacOS arm64 Node.js v22.18.0
// Expected JA3 Hash: 70cb5ca646080902703ffda87036a5ea
// Expected JA4: t13d5912h1_a33745022dd6_dbd39dd1d406
// Linux x64 Node.js v22.17.1 (explicit profile)
Profile
:
&
Profile
{
Name
:
"
macos_arm
64_node_v221
80
"
,
Name
:
"
linux_x
64_node_v221
71
"
,
EnableGREASE
:
false
,
CipherSuites
:
[]
uint16
{
4866
,
4867
,
4865
,
49199
,
49195
,
49200
,
49196
,
158
,
49191
,
103
,
49192
,
107
,
163
,
159
,
52393
,
52392
,
52394
,
49327
,
49325
,
49315
,
49311
,
49245
,
49249
,
49239
,
49235
,
162
,
49326
,
49324
,
49314
,
49310
,
49244
,
49248
,
49238
,
49234
,
49188
,
106
,
49187
,
64
,
49162
,
49172
,
57
,
56
,
49161
,
49171
,
51
,
50
,
157
,
49313
,
49309
,
49233
,
156
,
49312
,
49308
,
49232
,
61
,
60
,
53
,
47
,
255
},
Curves
:
[]
uint16
{
29
,
23
,
30
,
25
,
24
,
256
,
257
,
258
,
259
,
260
},
PointFormats
:
[]
uint8
{
0
,
1
,
2
},
PointFormats
:
[]
uint16
{
0
,
1
,
2
},
Extensions
:
[]
uint16
{
0
,
11
,
10
,
35
,
16
,
22
,
23
,
13
,
43
,
45
,
51
},
},
JA4CipherHash
:
"a33745022dd6"
,
// stable part (same cipher suites)
JA4CipherHash
:
"a33745022dd6"
,
},
}
...
...
backend/internal/pkg/tlsfingerprint/registry.go
deleted
100644 → 0
View file @
fa68cbad
// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients.
package
tlsfingerprint
import
(
"log/slog"
"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
{
slog
.
Debug
(
"tls_registry_disabled"
,
"reason"
,
"disabled or no config"
)
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
)
slog
.
Debug
(
"tls_registry_loaded_profile"
,
"key"
,
name
,
"name"
,
profileCfg
.
Name
)
}
slog
.
Debug
(
"tls_registry_initialized"
,
"profile_count"
,
len
(
r
.
profileNames
),
"profiles"
,
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
deleted
100644 → 0
View file @
fa68cbad
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/pkg/tlsfingerprint/test_types_test.go
View file @
b6d46fd5
...
...
@@ -8,6 +8,14 @@ type FingerprintResponse struct {
HTTP2
any
`json:"http2"`
}
// TestProfileExpectation defines expected fingerprint values for a profile.
type
TestProfileExpectation
struct
{
Profile
*
Profile
ExpectedJA3
string
// Expected JA3 hash (empty = don't check)
ExpectedJA4
string
// Expected full JA4 (empty = don't check)
JA4CipherHash
string
// Expected JA4 cipher hash - the stable middle part (empty = don't check)
}
// TLSInfo contains TLS fingerprint details.
type
TLSInfo
struct
{
JA3
string
`json:"ja3"`
...
...
backend/internal/repository/claude_oauth_service.go
View file @
b6d46fd5
...
...
@@ -212,7 +212,7 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod
SetContext
(
ctx
)
.
SetHeader
(
"Accept"
,
"application/json, text/plain, */*"
)
.
SetHeader
(
"Content-Type"
,
"application/json"
)
.
SetHeader
(
"User-Agent"
,
"axios/1.
8.4
"
)
.
SetHeader
(
"User-Agent"
,
"axios/1.
13.6
"
)
.
SetBody
(
reqBody
)
.
SetSuccessResult
(
&
tokenResp
)
.
Post
(
s
.
tokenURL
)
...
...
@@ -250,7 +250,7 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro
SetContext
(
ctx
)
.
SetHeader
(
"Accept"
,
"application/json, text/plain, */*"
)
.
SetHeader
(
"Content-Type"
,
"application/json"
)
.
SetHeader
(
"User-Agent"
,
"axios/1.
8.4
"
)
.
SetHeader
(
"User-Agent"
,
"axios/1.
13.6
"
)
.
SetBody
(
reqBody
)
.
SetSuccessResult
(
&
tokenResp
)
.
Post
(
s
.
tokenURL
)
...
...
backend/internal/repository/claude_usage_service.go
View file @
b6d46fd5
...
...
@@ -68,10 +68,9 @@ func (s *claudeUsageService) FetchUsageWithOptions(ctx context.Context, opts *se
var
resp
*
http
.
Response
// 如果启用 TLS 指纹且有 HTTPUpstream,使用 DoWithTLS
if
opts
.
EnableTLSFingerprint
&&
s
.
httpUpstream
!=
nil
{
// accountConcurrency 传 0 使用默认连接池配置,usage 请求不需要特殊的并发设置
resp
,
err
=
s
.
httpUpstream
.
DoWithTLS
(
req
,
opts
.
ProxyURL
,
opts
.
AccountID
,
0
,
true
)
// 如果有 TLS Profile 且有 HTTPUpstream,使用 DoWithTLS
if
opts
.
TLSProfile
!=
nil
&&
s
.
httpUpstream
!=
nil
{
resp
,
err
=
s
.
httpUpstream
.
DoWithTLS
(
req
,
opts
.
ProxyURL
,
opts
.
AccountID
,
0
,
opts
.
TLSProfile
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"request with TLS fingerprint failed: %w"
,
err
)
}
...
...
backend/internal/repository/http_upstream.go
View file @
b6d46fd5
package
repository
import
(
"compress/flate"
"compress/gzip"
"errors"
"fmt"
"io"
...
...
@@ -13,6 +15,8 @@ import (
"sync/atomic"
"time"
"github.com/andybalholm/brotli"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
...
...
@@ -143,6 +147,9 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
return
nil
,
err
}
// 如果上游返回了压缩内容,解压后再交给业务层
decompressResponseBody
(
resp
)
// 包装响应体,在关闭时自动减少计数并更新时间戳
// 这确保了流式响应(如 SSE)在完全读取前不会被淘汰
resp
.
Body
=
wrapTrackedBody
(
resp
.
Body
,
func
()
{
...
...
@@ -154,26 +161,14 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
}
// 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
{
// profile 为 nil 时不启用 TLS 指纹,行为与 Do 方法相同。
// profile 非 nil 时使用指定的 Profile 进行 TLS 指纹伪装。
func
(
s
*
httpUpstreamService
)
DoWithTLS
(
req
*
http
.
Request
,
proxyURL
string
,
accountID
int64
,
accountConcurrency
int
,
profile
*
tlsfingerprint
.
Profile
)
(
*
http
.
Response
,
error
)
{
if
profile
==
nil
{
return
s
.
Do
(
req
,
proxyURL
,
accountID
,
accountConcurrency
)
}
// TLS 指纹已启用,记录调试日志
targetHost
:=
""
if
req
!=
nil
&&
req
.
URL
!=
nil
{
targetHost
=
req
.
URL
.
Host
...
...
@@ -182,43 +177,28 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
if
proxyURL
!=
""
{
proxyInfo
=
proxyURL
}
slog
.
Debug
(
"tls_fingerprint_enabled"
,
"account_id"
,
accountID
,
"target"
,
targetHost
,
"proxy"
,
proxyInfo
)
slog
.
Debug
(
"tls_fingerprint_enabled"
,
"account_id"
,
accountID
,
"target"
,
targetHost
,
"proxy"
,
proxyInfo
,
"profile"
,
profile
.
Name
)
if
err
:=
s
.
validateRequestHost
(
req
);
err
!=
nil
{
return
nil
,
err
}
// 获取 TLS 指纹 Profile
registry
:=
tlsfingerprint
.
GlobalRegistry
()
profile
:=
registry
.
GetProfileByAccountID
(
accountID
)
if
profile
==
nil
{
// 如果获取不到 profile,回退到普通请求
slog
.
Debug
(
"tls_fingerprint_no_profile"
,
"account_id"
,
accountID
,
"fallback"
,
"standard_request"
)
return
s
.
Do
(
req
,
proxyURL
,
accountID
,
accountConcurrency
)
}
slog
.
Debug
(
"tls_fingerprint_using_profile"
,
"account_id"
,
accountID
,
"profile"
,
profile
.
Name
,
"grease"
,
profile
.
EnableGREASE
)
// 获取或创建带 TLS 指纹的客户端
entry
,
err
:=
s
.
acquireClientWithTLS
(
proxyURL
,
accountID
,
accountConcurrency
,
profile
)
if
err
!=
nil
{
slog
.
Debug
(
"tls_fingerprint_acquire_client_failed"
,
"account_id"
,
accountID
,
"error"
,
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
())
slog
.
Debug
(
"tls_fingerprint_request_failed"
,
"account_id"
,
accountID
,
"error"
,
err
)
return
nil
,
err
}
slog
.
Debug
(
"tls_fingerprint_request_success"
,
"account_id"
,
accountID
,
"status"
,
resp
.
StatusCode
)
decompressResponseBody
(
resp
)
// 包装响应体,在关闭时自动减少计数并更新时间戳
resp
.
Body
=
wrapTrackedBody
(
resp
.
Body
,
func
()
{
atomic
.
AddInt64
(
&
entry
.
inFlight
,
-
1
)
atomic
.
StoreInt64
(
&
entry
.
lastUsed
,
time
.
Now
()
.
UnixNano
())
...
...
@@ -884,3 +864,56 @@ func wrapTrackedBody(body io.ReadCloser, onClose func()) io.ReadCloser {
}
return
&
trackedBody
{
ReadCloser
:
body
,
onClose
:
onClose
}
}
// decompressResponseBody 根据 Content-Encoding 解压响应体。
// 当请求显式设置了 accept-encoding 时,Go 的 Transport 不会自动解压,需要手动处理。
// 解压成功后会删除 Content-Encoding 和 Content-Length header(长度已不准确)。
func
decompressResponseBody
(
resp
*
http
.
Response
)
{
if
resp
==
nil
||
resp
.
Body
==
nil
{
return
}
ce
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
resp
.
Header
.
Get
(
"Content-Encoding"
)))
if
ce
==
""
{
return
}
var
reader
io
.
Reader
switch
ce
{
case
"gzip"
:
gr
,
err
:=
gzip
.
NewReader
(
resp
.
Body
)
if
err
!=
nil
{
return
// 解压失败,保持原样
}
reader
=
gr
case
"br"
:
reader
=
brotli
.
NewReader
(
resp
.
Body
)
case
"deflate"
:
reader
=
flate
.
NewReader
(
resp
.
Body
)
default
:
return
}
originalBody
:=
resp
.
Body
resp
.
Body
=
&
decompressedBody
{
reader
:
reader
,
closer
:
originalBody
}
resp
.
Header
.
Del
(
"Content-Encoding"
)
resp
.
Header
.
Del
(
"Content-Length"
)
// 解压后长度不确定
resp
.
ContentLength
=
-
1
}
// decompressedBody 组合解压 reader 和原始 body 的 close。
type
decompressedBody
struct
{
reader
io
.
Reader
closer
io
.
Closer
}
func
(
d
*
decompressedBody
)
Read
(
p
[]
byte
)
(
int
,
error
)
{
return
d
.
reader
.
Read
(
p
)
}
func
(
d
*
decompressedBody
)
Close
()
error
{
// 如果 reader 本身也是 Closer(如 gzip.Reader),先关闭它
if
rc
,
ok
:=
d
.
reader
.
(
io
.
Closer
);
ok
{
_
=
rc
.
Close
()
}
return
d
.
closer
.
Close
()
}
backend/internal/repository/tls_fingerprint_profile_cache.go
0 → 100644
View file @
b6d46fd5
package
repository
import
(
"context"
"encoding/json"
"log/slog"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
)
const
(
tlsFPProfileCacheKey
=
"tls_fingerprint_profiles"
tlsFPProfilePubSubKey
=
"tls_fingerprint_profiles_updated"
tlsFPProfileCacheTTL
=
24
*
time
.
Hour
)
type
tlsFingerprintProfileCache
struct
{
rdb
*
redis
.
Client
localCache
[]
*
model
.
TLSFingerprintProfile
localMu
sync
.
RWMutex
}
// NewTLSFingerprintProfileCache 创建 TLS 指纹模板缓存
func
NewTLSFingerprintProfileCache
(
rdb
*
redis
.
Client
)
service
.
TLSFingerprintProfileCache
{
return
&
tlsFingerprintProfileCache
{
rdb
:
rdb
,
}
}
// Get 从缓存获取模板列表
func
(
c
*
tlsFingerprintProfileCache
)
Get
(
ctx
context
.
Context
)
([]
*
model
.
TLSFingerprintProfile
,
bool
)
{
c
.
localMu
.
RLock
()
if
c
.
localCache
!=
nil
{
profiles
:=
c
.
localCache
c
.
localMu
.
RUnlock
()
return
profiles
,
true
}
c
.
localMu
.
RUnlock
()
data
,
err
:=
c
.
rdb
.
Get
(
ctx
,
tlsFPProfileCacheKey
)
.
Bytes
()
if
err
!=
nil
{
if
err
!=
redis
.
Nil
{
slog
.
Warn
(
"tls_fp_profile_cache_get_failed"
,
"error"
,
err
)
}
return
nil
,
false
}
var
profiles
[]
*
model
.
TLSFingerprintProfile
if
err
:=
json
.
Unmarshal
(
data
,
&
profiles
);
err
!=
nil
{
slog
.
Warn
(
"tls_fp_profile_cache_unmarshal_failed"
,
"error"
,
err
)
return
nil
,
false
}
c
.
localMu
.
Lock
()
c
.
localCache
=
profiles
c
.
localMu
.
Unlock
()
return
profiles
,
true
}
// Set 设置缓存
func
(
c
*
tlsFingerprintProfileCache
)
Set
(
ctx
context
.
Context
,
profiles
[]
*
model
.
TLSFingerprintProfile
)
error
{
data
,
err
:=
json
.
Marshal
(
profiles
)
if
err
!=
nil
{
return
err
}
if
err
:=
c
.
rdb
.
Set
(
ctx
,
tlsFPProfileCacheKey
,
data
,
tlsFPProfileCacheTTL
)
.
Err
();
err
!=
nil
{
return
err
}
c
.
localMu
.
Lock
()
c
.
localCache
=
profiles
c
.
localMu
.
Unlock
()
return
nil
}
// Invalidate 使缓存失效
func
(
c
*
tlsFingerprintProfileCache
)
Invalidate
(
ctx
context
.
Context
)
error
{
c
.
localMu
.
Lock
()
c
.
localCache
=
nil
c
.
localMu
.
Unlock
()
return
c
.
rdb
.
Del
(
ctx
,
tlsFPProfileCacheKey
)
.
Err
()
}
// NotifyUpdate 通知其他实例刷新缓存
func
(
c
*
tlsFingerprintProfileCache
)
NotifyUpdate
(
ctx
context
.
Context
)
error
{
return
c
.
rdb
.
Publish
(
ctx
,
tlsFPProfilePubSubKey
,
"refresh"
)
.
Err
()
}
// SubscribeUpdates 订阅缓存更新通知
func
(
c
*
tlsFingerprintProfileCache
)
SubscribeUpdates
(
ctx
context
.
Context
,
handler
func
())
{
go
func
()
{
sub
:=
c
.
rdb
.
Subscribe
(
ctx
,
tlsFPProfilePubSubKey
)
defer
func
()
{
_
=
sub
.
Close
()
}()
ch
:=
sub
.
Channel
()
for
{
select
{
case
<-
ctx
.
Done
()
:
slog
.
Debug
(
"tls_fp_profile_cache_subscriber_stopped"
,
"reason"
,
"context_done"
)
return
case
msg
:=
<-
ch
:
if
msg
==
nil
{
slog
.
Warn
(
"tls_fp_profile_cache_subscriber_stopped"
,
"reason"
,
"channel_closed"
)
return
}
c
.
localMu
.
Lock
()
c
.
localCache
=
nil
c
.
localMu
.
Unlock
()
handler
()
}
}
}()
}
backend/internal/repository/tls_fingerprint_profile_repo.go
0 → 100644
View file @
b6d46fd5
package
repository
import
(
"context"
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type
tlsFingerprintProfileRepository
struct
{
client
*
ent
.
Client
}
// NewTLSFingerprintProfileRepository 创建 TLS 指纹模板仓库
func
NewTLSFingerprintProfileRepository
(
client
*
ent
.
Client
)
service
.
TLSFingerprintProfileRepository
{
return
&
tlsFingerprintProfileRepository
{
client
:
client
}
}
// List 获取所有模板
func
(
r
*
tlsFingerprintProfileRepository
)
List
(
ctx
context
.
Context
)
([]
*
model
.
TLSFingerprintProfile
,
error
)
{
profiles
,
err
:=
r
.
client
.
TLSFingerprintProfile
.
Query
()
.
Order
(
ent
.
Asc
(
tlsfingerprintprofile
.
FieldName
))
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
result
:=
make
([]
*
model
.
TLSFingerprintProfile
,
len
(
profiles
))
for
i
,
p
:=
range
profiles
{
result
[
i
]
=
r
.
toModel
(
p
)
}
return
result
,
nil
}
// GetByID 根据 ID 获取模板
func
(
r
*
tlsFingerprintProfileRepository
)
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
model
.
TLSFingerprintProfile
,
error
)
{
p
,
err
:=
r
.
client
.
TLSFingerprintProfile
.
Get
(
ctx
,
id
)
if
err
!=
nil
{
if
ent
.
IsNotFound
(
err
)
{
return
nil
,
nil
}
return
nil
,
err
}
return
r
.
toModel
(
p
),
nil
}
// Create 创建模板
func
(
r
*
tlsFingerprintProfileRepository
)
Create
(
ctx
context
.
Context
,
p
*
model
.
TLSFingerprintProfile
)
(
*
model
.
TLSFingerprintProfile
,
error
)
{
builder
:=
r
.
client
.
TLSFingerprintProfile
.
Create
()
.
SetName
(
p
.
Name
)
.
SetEnableGrease
(
p
.
EnableGREASE
)
if
p
.
Description
!=
nil
{
builder
.
SetDescription
(
*
p
.
Description
)
}
if
len
(
p
.
CipherSuites
)
>
0
{
builder
.
SetCipherSuites
(
p
.
CipherSuites
)
}
if
len
(
p
.
Curves
)
>
0
{
builder
.
SetCurves
(
p
.
Curves
)
}
if
len
(
p
.
PointFormats
)
>
0
{
builder
.
SetPointFormats
(
p
.
PointFormats
)
}
if
len
(
p
.
SignatureAlgorithms
)
>
0
{
builder
.
SetSignatureAlgorithms
(
p
.
SignatureAlgorithms
)
}
if
len
(
p
.
ALPNProtocols
)
>
0
{
builder
.
SetAlpnProtocols
(
p
.
ALPNProtocols
)
}
if
len
(
p
.
SupportedVersions
)
>
0
{
builder
.
SetSupportedVersions
(
p
.
SupportedVersions
)
}
if
len
(
p
.
KeyShareGroups
)
>
0
{
builder
.
SetKeyShareGroups
(
p
.
KeyShareGroups
)
}
if
len
(
p
.
PSKModes
)
>
0
{
builder
.
SetPskModes
(
p
.
PSKModes
)
}
if
len
(
p
.
Extensions
)
>
0
{
builder
.
SetExtensions
(
p
.
Extensions
)
}
created
,
err
:=
builder
.
Save
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
return
r
.
toModel
(
created
),
nil
}
// Update 更新模板
func
(
r
*
tlsFingerprintProfileRepository
)
Update
(
ctx
context
.
Context
,
p
*
model
.
TLSFingerprintProfile
)
(
*
model
.
TLSFingerprintProfile
,
error
)
{
builder
:=
r
.
client
.
TLSFingerprintProfile
.
UpdateOneID
(
p
.
ID
)
.
SetName
(
p
.
Name
)
.
SetEnableGrease
(
p
.
EnableGREASE
)
if
p
.
Description
!=
nil
{
builder
.
SetDescription
(
*
p
.
Description
)
}
else
{
builder
.
ClearDescription
()
}
if
len
(
p
.
CipherSuites
)
>
0
{
builder
.
SetCipherSuites
(
p
.
CipherSuites
)
}
else
{
builder
.
ClearCipherSuites
()
}
if
len
(
p
.
Curves
)
>
0
{
builder
.
SetCurves
(
p
.
Curves
)
}
else
{
builder
.
ClearCurves
()
}
if
len
(
p
.
PointFormats
)
>
0
{
builder
.
SetPointFormats
(
p
.
PointFormats
)
}
else
{
builder
.
ClearPointFormats
()
}
if
len
(
p
.
SignatureAlgorithms
)
>
0
{
builder
.
SetSignatureAlgorithms
(
p
.
SignatureAlgorithms
)
}
else
{
builder
.
ClearSignatureAlgorithms
()
}
if
len
(
p
.
ALPNProtocols
)
>
0
{
builder
.
SetAlpnProtocols
(
p
.
ALPNProtocols
)
}
else
{
builder
.
ClearAlpnProtocols
()
}
if
len
(
p
.
SupportedVersions
)
>
0
{
builder
.
SetSupportedVersions
(
p
.
SupportedVersions
)
}
else
{
builder
.
ClearSupportedVersions
()
}
if
len
(
p
.
KeyShareGroups
)
>
0
{
builder
.
SetKeyShareGroups
(
p
.
KeyShareGroups
)
}
else
{
builder
.
ClearKeyShareGroups
()
}
if
len
(
p
.
PSKModes
)
>
0
{
builder
.
SetPskModes
(
p
.
PSKModes
)
}
else
{
builder
.
ClearPskModes
()
}
if
len
(
p
.
Extensions
)
>
0
{
builder
.
SetExtensions
(
p
.
Extensions
)
}
else
{
builder
.
ClearExtensions
()
}
updated
,
err
:=
builder
.
Save
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
return
r
.
toModel
(
updated
),
nil
}
// Delete 删除模板
func
(
r
*
tlsFingerprintProfileRepository
)
Delete
(
ctx
context
.
Context
,
id
int64
)
error
{
return
r
.
client
.
TLSFingerprintProfile
.
DeleteOneID
(
id
)
.
Exec
(
ctx
)
}
// toModel 将 Ent 实体转换为服务模型
func
(
r
*
tlsFingerprintProfileRepository
)
toModel
(
e
*
ent
.
TLSFingerprintProfile
)
*
model
.
TLSFingerprintProfile
{
p
:=
&
model
.
TLSFingerprintProfile
{
ID
:
e
.
ID
,
Name
:
e
.
Name
,
Description
:
e
.
Description
,
EnableGREASE
:
e
.
EnableGrease
,
CipherSuites
:
e
.
CipherSuites
,
Curves
:
e
.
Curves
,
PointFormats
:
e
.
PointFormats
,
SignatureAlgorithms
:
e
.
SignatureAlgorithms
,
ALPNProtocols
:
e
.
AlpnProtocols
,
SupportedVersions
:
e
.
SupportedVersions
,
KeyShareGroups
:
e
.
KeyShareGroups
,
PSKModes
:
e
.
PskModes
,
Extensions
:
e
.
Extensions
,
CreatedAt
:
e
.
CreatedAt
,
UpdatedAt
:
e
.
UpdatedAt
,
}
// 确保切片不为 nil
if
p
.
CipherSuites
==
nil
{
p
.
CipherSuites
=
[]
uint16
{}
}
if
p
.
Curves
==
nil
{
p
.
Curves
=
[]
uint16
{}
}
if
p
.
PointFormats
==
nil
{
p
.
PointFormats
=
[]
uint16
{}
}
if
p
.
SignatureAlgorithms
==
nil
{
p
.
SignatureAlgorithms
=
[]
uint16
{}
}
if
p
.
ALPNProtocols
==
nil
{
p
.
ALPNProtocols
=
[]
string
{}
}
if
p
.
SupportedVersions
==
nil
{
p
.
SupportedVersions
=
[]
uint16
{}
}
if
p
.
KeyShareGroups
==
nil
{
p
.
KeyShareGroups
=
[]
uint16
{}
}
if
p
.
PSKModes
==
nil
{
p
.
PSKModes
=
[]
uint16
{}
}
if
p
.
Extensions
==
nil
{
p
.
Extensions
=
[]
uint16
{}
}
return
p
}
backend/internal/repository/wire.go
View file @
b6d46fd5
...
...
@@ -73,6 +73,7 @@ var ProviderSet = wire.NewSet(
NewUserAttributeValueRepository
,
NewUserGroupRateRepository
,
NewErrorPassthroughRepository
,
NewTLSFingerprintProfileRepository
,
// Cache implementations
NewGatewayCache
,
...
...
@@ -96,6 +97,7 @@ var ProviderSet = wire.NewSet(
NewTotpCache
,
NewRefreshTokenCache
,
NewErrorPassthroughCache
,
NewTLSFingerprintProfileCache
,
// Encryptors
NewAESEncryptor
,
...
...
backend/internal/server/api_contract_test.go
View file @
b6d46fd5
...
...
@@ -540,6 +540,8 @@ func TestAPIContracts(t *testing.T) {
"max_claude_code_version": "",
"allow_ungrouped_key_scheduling": false,
"backend_mode_enabled": false,
"enable_fingerprint_unification": true,
"enable_metadata_passthrough": false,
"custom_menu_items": [],
"custom_endpoints": []
}
...
...
backend/internal/server/routes/admin.go
View file @
b6d46fd5
...
...
@@ -79,6 +79,9 @@ func RegisterAdminRoutes(
// 错误透传规则管理
registerErrorPassthroughRoutes
(
admin
,
h
)
// TLS 指纹模板管理
registerTLSFingerprintProfileRoutes
(
admin
,
h
)
// API Key 管理
registerAdminAPIKeyRoutes
(
admin
,
h
)
...
...
@@ -257,6 +260,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
accounts
.
POST
(
"/:id/test"
,
h
.
Admin
.
Account
.
Test
)
accounts
.
POST
(
"/:id/recover-state"
,
h
.
Admin
.
Account
.
RecoverState
)
accounts
.
POST
(
"/:id/refresh"
,
h
.
Admin
.
Account
.
Refresh
)
accounts
.
POST
(
"/:id/set-privacy"
,
h
.
Admin
.
Account
.
SetPrivacy
)
accounts
.
POST
(
"/:id/refresh-tier"
,
h
.
Admin
.
Account
.
RefreshTier
)
accounts
.
GET
(
"/:id/stats"
,
h
.
Admin
.
Account
.
GetStats
)
accounts
.
POST
(
"/:id/clear-error"
,
h
.
Admin
.
Account
.
ClearError
)
...
...
@@ -552,3 +556,14 @@ func registerErrorPassthroughRoutes(admin *gin.RouterGroup, h *handler.Handlers)
rules
.
DELETE
(
"/:id"
,
h
.
Admin
.
ErrorPassthrough
.
Delete
)
}
}
func
registerTLSFingerprintProfileRoutes
(
admin
*
gin
.
RouterGroup
,
h
*
handler
.
Handlers
)
{
profiles
:=
admin
.
Group
(
"/tls-fingerprint-profiles"
)
{
profiles
.
GET
(
""
,
h
.
Admin
.
TLSFingerprintProfile
.
List
)
profiles
.
GET
(
"/:id"
,
h
.
Admin
.
TLSFingerprintProfile
.
GetByID
)
profiles
.
POST
(
""
,
h
.
Admin
.
TLSFingerprintProfile
.
Create
)
profiles
.
PUT
(
"/:id"
,
h
.
Admin
.
TLSFingerprintProfile
.
Update
)
profiles
.
DELETE
(
"/:id"
,
h
.
Admin
.
TLSFingerprintProfile
.
Delete
)
}
}
backend/internal/service/account.go
View file @
b6d46fd5
...
...
@@ -1165,6 +1165,31 @@ func (a *Account) IsTLSFingerprintEnabled() bool {
return
false
}
// GetTLSFingerprintProfileID 获取账号绑定的 TLS 指纹模板 ID
// 返回 0 表示未绑定(使用内置默认 profile)
func
(
a
*
Account
)
GetTLSFingerprintProfileID
()
int64
{
if
a
.
Extra
==
nil
{
return
0
}
v
,
ok
:=
a
.
Extra
[
"tls_fingerprint_profile_id"
]
if
!
ok
{
return
0
}
switch
id
:=
v
.
(
type
)
{
case
float64
:
return
int64
(
id
)
case
int64
:
return
id
case
int
:
return
int64
(
id
)
case
json
.
Number
:
if
i
,
err
:=
id
.
Int64
();
err
==
nil
{
return
i
}
}
return
0
}
// GetUserMsgQueueMode 获取用户消息队列模式
// "serialize" = 串行队列, "throttle" = 软性限速, "" = 未设置(使用全局配置)
func
(
a
*
Account
)
GetUserMsgQueueMode
()
string
{
...
...
backend/internal/service/account_test_service.go
View file @
b6d46fd5
...
...
@@ -23,6 +23,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/util/soraerror"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"github.com/gin-gonic/gin"
...
...
@@ -69,6 +70,7 @@ type AccountTestService struct {
antigravityGatewayService
*
AntigravityGatewayService
httpUpstream
HTTPUpstream
cfg
*
config
.
Config
tlsFPProfileService
*
TLSFingerprintProfileService
soraTestGuardMu
sync
.
Mutex
soraTestLastRun
map
[
int64
]
time
.
Time
soraTestCooldown
time
.
Duration
...
...
@@ -83,6 +85,7 @@ func NewAccountTestService(
antigravityGatewayService
*
AntigravityGatewayService
,
httpUpstream
HTTPUpstream
,
cfg
*
config
.
Config
,
tlsFPProfileService
*
TLSFingerprintProfileService
,
)
*
AccountTestService
{
return
&
AccountTestService
{
accountRepo
:
accountRepo
,
...
...
@@ -90,6 +93,7 @@ func NewAccountTestService(
antigravityGatewayService
:
antigravityGatewayService
,
httpUpstream
:
httpUpstream
,
cfg
:
cfg
,
tlsFPProfileService
:
tlsFPProfileService
,
soraTestLastRun
:
make
(
map
[
int64
]
time
.
Time
),
soraTestCooldown
:
defaultSoraTestCooldown
,
}
...
...
@@ -300,7 +304,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
proxyURL
=
account
.
Proxy
.
URL
()
}
resp
,
err
:=
s
.
httpUpstream
.
DoWithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
account
.
IsTLSFingerprintEnabled
(
))
resp
,
err
:=
s
.
httpUpstream
.
DoWithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
s
.
tlsFPProfileService
.
ResolveTLSProfile
(
account
))
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Request failed: %s"
,
err
.
Error
()))
}
...
...
@@ -390,7 +394,7 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co
proxyURL
=
account
.
Proxy
.
URL
()
}
resp
,
err
:=
s
.
httpUpstream
.
DoWithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
false
)
resp
,
err
:=
s
.
httpUpstream
.
DoWithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
nil
)
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Request failed: %s"
,
err
.
Error
()))
}
...
...
@@ -520,7 +524,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
proxyURL
=
account
.
Proxy
.
URL
()
}
resp
,
err
:=
s
.
httpUpstream
.
DoWithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
account
.
IsTLSFingerprintEnabled
(
))
resp
,
err
:=
s
.
httpUpstream
.
DoWithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
s
.
tlsFPProfileService
.
ResolveTLSProfile
(
account
))
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Request failed: %s"
,
err
.
Error
()))
}
...
...
@@ -610,7 +614,7 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
proxyURL
=
account
.
Proxy
.
URL
()
}
resp
,
err
:=
s
.
httpUpstream
.
DoWithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
account
.
IsTLSFingerprintEnabled
(
))
resp
,
err
:=
s
.
httpUpstream
.
DoWithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
s
.
tlsFPProfileService
.
ResolveTLSProfile
(
account
))
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Request failed: %s"
,
err
.
Error
()))
}
...
...
@@ -881,9 +885,9 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
if
account
.
ProxyID
!=
nil
&&
account
.
Proxy
!=
nil
{
proxyURL
=
account
.
Proxy
.
URL
()
}
enableSoraTLSFingerprint
:=
s
.
shouldEnableSoraTLSFingerprint
()
soraTLSProfile
:=
s
.
resolveSoraTLSProfile
()
resp
,
err
:=
s
.
httpUpstream
.
DoWithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
enableSoraTLSFingerprint
)
resp
,
err
:=
s
.
httpUpstream
.
DoWithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
soraTLSProfile
)
if
err
!=
nil
{
recorder
.
addStep
(
"me"
,
"failed"
,
0
,
"network_error"
,
err
.
Error
())
s
.
emitSoraProbeSummary
(
c
,
recorder
)
...
...
@@ -948,7 +952,7 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
subReq
.
Header
.
Set
(
"Origin"
,
"https://sora.chatgpt.com"
)
subReq
.
Header
.
Set
(
"Referer"
,
"https://sora.chatgpt.com/"
)
subResp
,
subErr
:=
s
.
httpUpstream
.
DoWithTLS
(
subReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
enableSoraTLSFingerprint
)
subResp
,
subErr
:=
s
.
httpUpstream
.
DoWithTLS
(
subReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
soraTLSProfile
)
if
subErr
!=
nil
{
recorder
.
addStep
(
"subscription"
,
"failed"
,
0
,
"network_error"
,
subErr
.
Error
())
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
fmt
.
Sprintf
(
"Subscription check skipped: %s"
,
subErr
.
Error
())})
...
...
@@ -977,7 +981,7 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
}
// 追加 Sora2 能力探测(对齐 sora2api 的测试思路):邀请码 + 剩余额度。
s
.
testSora2Capabilities
(
c
,
ctx
,
account
,
authToken
,
proxyURL
,
enableSoraTLSFingerprint
,
recorder
)
s
.
testSora2Capabilities
(
c
,
ctx
,
account
,
authToken
,
proxyURL
,
soraTLSProfile
,
recorder
)
s
.
emitSoraProbeSummary
(
c
,
recorder
)
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"test_complete"
,
Success
:
true
})
...
...
@@ -990,7 +994,7 @@ func (s *AccountTestService) testSora2Capabilities(
account
*
Account
,
authToken
string
,
proxyURL
string
,
enableTLSF
ingerprint
bool
,
tlsProfile
*
tlsf
ingerprint
.
Profile
,
recorder
*
soraProbeRecorder
,
)
{
inviteStatus
,
inviteHeader
,
inviteBody
,
err
:=
s
.
fetchSoraTestEndpoint
(
...
...
@@ -999,7 +1003,7 @@ func (s *AccountTestService) testSora2Capabilities(
authToken
,
soraInviteMineURL
,
proxyURL
,
enableTLSFingerprint
,
tlsProfile
,
)
if
err
!=
nil
{
if
recorder
!=
nil
{
...
...
@@ -1016,7 +1020,7 @@ func (s *AccountTestService) testSora2Capabilities(
authToken
,
soraBootstrapURL
,
proxyURL
,
enableTLSFingerprint
,
tlsProfile
,
)
if
bootstrapErr
==
nil
&&
bootstrapStatus
==
http
.
StatusOK
{
if
recorder
!=
nil
{
...
...
@@ -1029,7 +1033,7 @@ func (s *AccountTestService) testSora2Capabilities(
authToken
,
soraInviteMineURL
,
proxyURL
,
enableTLSFingerprint
,
tlsProfile
,
)
if
err
!=
nil
{
if
recorder
!=
nil
{
...
...
@@ -1081,7 +1085,7 @@ func (s *AccountTestService) testSora2Capabilities(
authToken
,
soraRemainingURL
,
proxyURL
,
enableTLSFingerprint
,
tlsProfile
,
)
if
remainingErr
!=
nil
{
if
recorder
!=
nil
{
...
...
@@ -1122,7 +1126,7 @@ func (s *AccountTestService) fetchSoraTestEndpoint(
authToken
string
,
url
string
,
proxyURL
string
,
enableTLSF
ingerprint
bool
,
tlsProfile
*
tlsf
ingerprint
.
Profile
,
)
(
int
,
http
.
Header
,
[]
byte
,
error
)
{
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
"GET"
,
url
,
nil
)
if
err
!=
nil
{
...
...
@@ -1135,7 +1139,7 @@ func (s *AccountTestService) fetchSoraTestEndpoint(
req
.
Header
.
Set
(
"Origin"
,
"https://sora.chatgpt.com"
)
req
.
Header
.
Set
(
"Referer"
,
"https://sora.chatgpt.com/"
)
resp
,
err
:=
s
.
httpUpstream
.
DoWithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
enableTLSFingerprint
)
resp
,
err
:=
s
.
httpUpstream
.
DoWithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
tlsProfile
)
if
err
!=
nil
{
return
0
,
nil
,
nil
,
err
}
...
...
@@ -1224,11 +1228,12 @@ func parseSoraRemainingSummary(body []byte) string {
return
strings
.
Join
(
parts
,
" | "
)
}
func
(
s
*
AccountTestService
)
shouldEnableSoraTLSFingerprint
()
bool
{
if
s
==
nil
||
s
.
cfg
==
nil
{
return
true
func
(
s
*
AccountTestService
)
resolveSoraTLSProfile
()
*
tlsfingerprint
.
Profile
{
if
s
==
nil
||
s
.
cfg
==
nil
||
!
s
.
cfg
.
Sora
.
Client
.
DisableTLSFingerprint
{
// Sora TLS fingerprint enabled — use built-in default profile
return
&
tlsfingerprint
.
Profile
{
Name
:
"Built-in Default (Sora)"
}
}
return
!
s
.
cfg
.
Sora
.
Client
.
DisableTLSFingerprint
return
nil
// disabled
}
func
isCloudflareChallengeResponse
(
statusCode
int
,
headers
http
.
Header
,
body
[]
byte
)
bool
{
...
...
backend/internal/service/account_test_service_sora_test.go
View file @
b6d46fd5
...
...
@@ -10,6 +10,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
...
...
@@ -24,9 +25,9 @@ func (u *queuedHTTPUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*htt
return
nil
,
fmt
.
Errorf
(
"unexpected Do call"
)
}
func
(
u
*
queuedHTTPUpstream
)
DoWithTLS
(
req
*
http
.
Request
,
_
string
,
_
int64
,
_
int
,
enableTLSF
ingerprint
bool
)
(
*
http
.
Response
,
error
)
{
func
(
u
*
queuedHTTPUpstream
)
DoWithTLS
(
req
*
http
.
Request
,
_
string
,
_
int64
,
_
int
,
profile
*
tlsf
ingerprint
.
Profile
)
(
*
http
.
Response
,
error
)
{
u
.
requests
=
append
(
u
.
requests
,
req
)
u
.
tlsFlags
=
append
(
u
.
tlsFlags
,
enableTLSFingerprint
)
u
.
tlsFlags
=
append
(
u
.
tlsFlags
,
profile
!=
nil
)
if
len
(
u
.
responses
)
==
0
{
return
nil
,
fmt
.
Errorf
(
"no mocked response"
)
}
...
...
backend/internal/service/account_usage_service.go
View file @
b6d46fd5
...
...
@@ -17,6 +17,7 @@ import (
openaipkg
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/singleflight"
...
...
@@ -241,11 +242,11 @@ type ClaudeUsageResponse struct {
// ClaudeUsageFetchOptions 包含获取 Claude 用量数据所需的所有选项
type
ClaudeUsageFetchOptions
struct
{
AccessToken
string
// OAuth access token
ProxyURL
string
// 代理 URL(可选)
AccountID
int64
// 账号 ID(用于
TLS 指纹选择
)
EnableTLSFingerprint
bool
// 是否启用 TLS 指纹伪装
Fingerprint
*
Fingerprint
// 缓存的指纹信息(User-Agent 等)
AccessToken
string
// OAuth access token
ProxyURL
string
// 代理 URL(可选)
AccountID
int64
// 账号 ID(用于
连接池隔离
)
TLSProfile
*
tlsfingerprint
.
Profile
// TLS 指纹 Profile(nil 表示不启用)
Fingerprint
*
Fingerprint
// 缓存的指纹信息(User-Agent 等)
}
// ClaudeUsageFetcher fetches usage data from Anthropic OAuth API
...
...
@@ -264,6 +265,7 @@ type AccountUsageService struct {
antigravityQuotaFetcher
*
AntigravityQuotaFetcher
cache
*
UsageCache
identityCache
IdentityCache
tlsFPProfileService
*
TLSFingerprintProfileService
}
// NewAccountUsageService 创建AccountUsageService实例
...
...
@@ -275,6 +277,7 @@ func NewAccountUsageService(
antigravityQuotaFetcher
*
AntigravityQuotaFetcher
,
cache
*
UsageCache
,
identityCache
IdentityCache
,
tlsFPProfileService
*
TLSFingerprintProfileService
,
)
*
AccountUsageService
{
return
&
AccountUsageService
{
accountRepo
:
accountRepo
,
...
...
@@ -284,6 +287,7 @@ func NewAccountUsageService(
antigravityQuotaFetcher
:
antigravityQuotaFetcher
,
cache
:
cache
,
identityCache
:
identityCache
,
tlsFPProfileService
:
tlsFPProfileService
,
}
}
...
...
@@ -1155,10 +1159,10 @@ func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *A
// 构建完整的选项
opts
:=
&
ClaudeUsageFetchOptions
{
AccessToken
:
accessToken
,
ProxyURL
:
proxyURL
,
AccountID
:
account
.
ID
,
EnableTLSFingerprint
:
account
.
IsTLSFingerprintEnabled
(
),
AccessToken
:
accessToken
,
ProxyURL
:
proxyURL
,
AccountID
:
account
.
ID
,
TLSProfile
:
s
.
tlsFPProfileService
.
ResolveTLSProfile
(
account
),
}
// 尝试获取缓存的 Fingerprint(包含 User-Agent 等信息)
...
...
backend/internal/service/admin_service.go
View file @
b6d46fd5
...
...
@@ -65,6 +65,12 @@ type AdminService interface {
SetAccountError
(
ctx
context
.
Context
,
id
int64
,
errorMsg
string
)
error
// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号 privacy_mode,未设置则尝试关闭训练数据共享并持久化。
EnsureOpenAIPrivacy
(
ctx
context
.
Context
,
account
*
Account
)
string
// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号 privacy_mode,未设置则调用 setUserSettings 并持久化。
EnsureAntigravityPrivacy
(
ctx
context
.
Context
,
account
*
Account
)
string
// ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。
ForceOpenAIPrivacy
(
ctx
context
.
Context
,
account
*
Account
)
string
// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。
ForceAntigravityPrivacy
(
ctx
context
.
Context
,
account
*
Account
)
string
SetAccountSchedulable
(
ctx
context
.
Context
,
id
int64
,
schedulable
bool
)
(
*
Account
,
error
)
BulkUpdateAccounts
(
ctx
context
.
Context
,
input
*
BulkUpdateAccountsInput
)
(
*
BulkUpdateAccountsResult
,
error
)
CheckMixedChannelRisk
(
ctx
context
.
Context
,
currentAccountID
int64
,
currentAccountPlatform
string
,
groupIDs
[]
int64
)
error
...
...
@@ -2635,10 +2641,8 @@ func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Acc
if
s
.
privacyClientFactory
==
nil
{
return
""
}
if
account
.
Extra
!=
nil
{
if
_
,
ok
:=
account
.
Extra
[
"privacy_mode"
];
ok
{
return
""
}
if
shouldSkipOpenAIPrivacyEnsure
(
account
.
Extra
)
{
return
""
}
token
,
_
:=
account
.
Credentials
[
"access_token"
]
.
(
string
)
...
...
@@ -2661,3 +2665,115 @@ func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Acc
_
=
s
.
accountRepo
.
UpdateExtra
(
ctx
,
account
.
ID
,
map
[
string
]
any
{
"privacy_mode"
:
mode
})
return
mode
}
// ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。
func
(
s
*
adminServiceImpl
)
ForceOpenAIPrivacy
(
ctx
context
.
Context
,
account
*
Account
)
string
{
if
account
.
Platform
!=
PlatformOpenAI
||
account
.
Type
!=
AccountTypeOAuth
{
return
""
}
if
s
.
privacyClientFactory
==
nil
{
return
""
}
token
,
_
:=
account
.
Credentials
[
"access_token"
]
.
(
string
)
if
token
==
""
{
return
""
}
var
proxyURL
string
if
account
.
ProxyID
!=
nil
{
if
p
,
err
:=
s
.
proxyRepo
.
GetByID
(
ctx
,
*
account
.
ProxyID
);
err
==
nil
&&
p
!=
nil
{
proxyURL
=
p
.
URL
()
}
}
mode
:=
disableOpenAITraining
(
ctx
,
s
.
privacyClientFactory
,
token
,
proxyURL
)
if
mode
==
""
{
return
""
}
if
err
:=
s
.
accountRepo
.
UpdateExtra
(
ctx
,
account
.
ID
,
map
[
string
]
any
{
"privacy_mode"
:
mode
});
err
!=
nil
{
logger
.
LegacyPrintf
(
"service.admin"
,
"force_update_openai_privacy_mode_failed: account_id=%d err=%v"
,
account
.
ID
,
err
)
return
mode
}
if
account
.
Extra
==
nil
{
account
.
Extra
=
make
(
map
[
string
]
any
)
}
account
.
Extra
[
"privacy_mode"
]
=
mode
return
mode
}
// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。
// 如果 Extra["privacy_mode"] 已存在(无论成功或失败),直接跳过。
// 仅对从未设置过隐私的账号执行 setUserSettings + fetchUserInfo 流程。
// 用户可通过前端 ForceAntigravityPrivacy(SetPrivacy 按钮)强制重新设置。
func
(
s
*
adminServiceImpl
)
EnsureAntigravityPrivacy
(
ctx
context
.
Context
,
account
*
Account
)
string
{
if
account
.
Platform
!=
PlatformAntigravity
||
account
.
Type
!=
AccountTypeOAuth
{
return
""
}
// 已设置过则跳过(无论成功或失败),用户可通过 Force 手动重试
if
account
.
Extra
!=
nil
{
if
existing
,
ok
:=
account
.
Extra
[
"privacy_mode"
]
.
(
string
);
ok
&&
existing
!=
""
{
return
existing
}
}
token
,
_
:=
account
.
Credentials
[
"access_token"
]
.
(
string
)
if
token
==
""
{
return
""
}
projectID
,
_
:=
account
.
Credentials
[
"project_id"
]
.
(
string
)
var
proxyURL
string
if
account
.
ProxyID
!=
nil
{
if
p
,
err
:=
s
.
proxyRepo
.
GetByID
(
ctx
,
*
account
.
ProxyID
);
err
==
nil
&&
p
!=
nil
{
proxyURL
=
p
.
URL
()
}
}
mode
:=
setAntigravityPrivacy
(
ctx
,
token
,
projectID
,
proxyURL
)
if
mode
==
""
{
return
""
}
if
err
:=
s
.
accountRepo
.
UpdateExtra
(
ctx
,
account
.
ID
,
map
[
string
]
any
{
"privacy_mode"
:
mode
});
err
!=
nil
{
logger
.
LegacyPrintf
(
"service.admin"
,
"update_antigravity_privacy_mode_failed: account_id=%d err=%v"
,
account
.
ID
,
err
)
return
mode
}
applyAntigravityPrivacyMode
(
account
,
mode
)
return
mode
}
// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。
func
(
s
*
adminServiceImpl
)
ForceAntigravityPrivacy
(
ctx
context
.
Context
,
account
*
Account
)
string
{
if
account
.
Platform
!=
PlatformAntigravity
||
account
.
Type
!=
AccountTypeOAuth
{
return
""
}
token
,
_
:=
account
.
Credentials
[
"access_token"
]
.
(
string
)
if
token
==
""
{
return
""
}
projectID
,
_
:=
account
.
Credentials
[
"project_id"
]
.
(
string
)
var
proxyURL
string
if
account
.
ProxyID
!=
nil
{
if
p
,
err
:=
s
.
proxyRepo
.
GetByID
(
ctx
,
*
account
.
ProxyID
);
err
==
nil
&&
p
!=
nil
{
proxyURL
=
p
.
URL
()
}
}
mode
:=
setAntigravityPrivacy
(
ctx
,
token
,
projectID
,
proxyURL
)
if
mode
==
""
{
return
""
}
if
err
:=
s
.
accountRepo
.
UpdateExtra
(
ctx
,
account
.
ID
,
map
[
string
]
any
{
"privacy_mode"
:
mode
});
err
!=
nil
{
logger
.
LegacyPrintf
(
"service.admin"
,
"force_update_antigravity_privacy_mode_failed: account_id=%d err=%v"
,
account
.
ID
,
err
)
return
mode
}
applyAntigravityPrivacyMode
(
account
,
mode
)
return
mode
}
Prev
1
2
3
4
5
6
Next
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