Unverified Commit b6d46fd5 authored by InCerryGit's avatar InCerryGit Committed by GitHub
Browse files

Merge branch 'Wei-Shaw:main' into main

parents fa68cbad fdd8499f
......@@ -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
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
0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
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{
// 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))
// 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")
}
spec := buildClientHelloSpecFromProfile(profile)
tlsConn := utls.UClient(conn, &utls.Config{ServerName: host}, utls.HelloCustom)
// 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}
}
// Determine extension order
extOrder := defaultExtensionOrder
if profile != nil && len(profile.Extensions) > 0 {
extOrder = profile.Extensions
}
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
}
// SNI extension - MUST be explicitly added for HelloCustom mode
// utls will populate the server name from Config.ServerName
switch id {
case 0: // server_name
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 {
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
}
//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")
}
......@@ -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 20.x)
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP)
// Expected JA3 hash: 44f88fca027f27bab4bb08d4af15f23e (Node.js 24.x)
// Expected JA4: t13d1714h1_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/20.0.0")
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/24.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)
} else {
t.Errorf("✗ JA4 suffix mismatch: got %s, expected suffix %s", fpResp.TLS.JA4, expectedJA4Suffix)
}
// Verify JA4 prefix (t13d5911h1 or t13i5911h1)
// d = domain (SNI present), i = IP (no SNI)
// Since we connect to tls.peet.ws (domain), we expect 'd'
expectedJA4Prefix := "t13d5911h1"
if strings.HasPrefix(fpResp.TLS.JA4, expectedJA4Prefix) {
t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 59=ciphers, 11=extensions, h1=HTTP/1.1)", expectedJA4Prefix)
} else {
// Also accept 'i' variant for IP connections
altPrefix := "t13i5911h1"
if strings.HasPrefix(fpResp.TLS.JA4, altPrefix) {
t.Logf("✓ JA4 prefix matches (IP variant): %s", altPrefix)
} else {
t.Errorf("✗ JA4 prefix mismatch: got %s, expected %s or %s", fpResp.TLS.JA4, expectedJA4Prefix, altPrefix)
}
}
// Verify JA3 contains expected cipher suites (TLS 1.3 ciphers at the beginning)
if strings.Contains(fpResp.TLS.JA3, "4866-4867-4865") {
t.Logf("✓ JA3 contains expected TLS 1.3 cipher suites")
expectedJA4CipherHash := "_5b57614c22b0_"
if strings.Contains(fpResp.TLS.JA4, expectedJA4CipherHash) {
t.Logf("✓ JA4 cipher hash matches: %s", expectedJA4CipherHash)
} else {
t.Logf("Warning: JA3 does not contain expected TLS 1.3 cipher suites")
t.Errorf("✗ JA4 cipher hash mismatch: got %s, expected containing %s", fpResp.TLS.JA4, expectedJA4CipherHash)
}
// 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_v22171",
Name: "default_node_v24",
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_arm64_node_v22180",
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: []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",
},
}
......
......@@ -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 20.x)
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP)
// Expected JA3 hash: 44f88fca027f27bab4bb08d4af15f23e (Node.js 24.x)
// Expected JA4: t13d1714h1_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/20.0.0")
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/24.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, expectedJA4Suffix)
t.Errorf("✗ JA4 cipher hash mismatch: got %s, expected containing %s", fpResp.TLS.JA4, expectedJA4CipherHash)
}
// 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, 11=extensions, h1=HTTP/1.1)", expectedJA4Prefix)
t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 17=ciphers, 14=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 := buildClientHelloSpecFromProfile(dialer1.profile)
spec2 := buildClientHelloSpecFromProfile(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_v22171",
Name: "default_node_v24",
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_arm64_node_v22180",
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: []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",
},
}
......
// 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
}
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)
}
......@@ -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"`
......
......@@ -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)
......
......@@ -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)
}
......
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()
}
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()
}
}
}()
}
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
}
......@@ -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,
......
......@@ -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": []
}
......
......@@ -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)
}
}
......@@ -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 {
......
......@@ -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,
enableTLSFingerprint bool,
tlsProfile *tlsfingerprint.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,
enableTLSFingerprint bool,
tlsProfile *tlsfingerprint.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 {
......
......@@ -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, enableTLSFingerprint bool) (*http.Response, error) {
func (u *queuedHTTPUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, profile *tlsfingerprint.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")
}
......
......@@ -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"
......@@ -243,8 +244,8 @@ type ClaudeUsageResponse struct {
type ClaudeUsageFetchOptions struct {
AccessToken string // OAuth access token
ProxyURL string // 代理 URL(可选)
AccountID int64 // 账号 ID(用于 TLS 指纹选择
EnableTLSFingerprint bool // 是否启用 TLS 指纹伪装
AccountID int64 // 账号 ID(用于连接池隔离
TLSProfile *tlsfingerprint.Profile // TLS 指纹 Profile(nil 表示不启用)
Fingerprint *Fingerprint // 缓存的指纹信息(User-Agent 等)
}
......@@ -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,
}
}
......@@ -1158,7 +1162,7 @@ func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *A
AccessToken: accessToken,
ProxyURL: proxyURL,
AccountID: account.ID,
EnableTLSFingerprint: account.IsTLSFingerprintEnabled(),
TLSProfile: s.tlsFPProfileService.ResolveTLSProfile(account),
}
// 尝试获取缓存的 Fingerprint(包含 User-Agent 等信息)
......
......@@ -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,11 +2641,9 @@ 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 {
if shouldSkipOpenAIPrivacyEnsure(account.Extra) {
return ""
}
}
token, _ := account.Credentials["access_token"].(string)
if token == "" {
......@@ -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
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment