Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s

This commit is contained in:
李建琦
2026-08-21 18:30:13 +08:00
commit 6d655c9903
3584 changed files with 1270640 additions and 0 deletions
@@ -0,0 +1,466 @@
// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients.
// It uses the utls library to create TLS connections that mimic Node.js/Claude Code clients.
package tlsfingerprint
import (
"bufio"
"context"
"encoding/base64"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
utls "github.com/refraction-networking/utls"
"golang.org/x/net/proxy"
)
// 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 []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.
type Dialer struct {
profile *Profile
baseDialer func(ctx context.Context, network, addr string) (net.Conn, error)
}
// HTTPProxyDialer creates TLS connections through HTTP/HTTPS proxies with custom fingerprints.
// It handles the CONNECT tunnel establishment before performing TLS handshake.
type HTTPProxyDialer struct {
profile *Profile
proxyURL *url.URL
}
// SOCKS5ProxyDialer creates TLS connections through SOCKS5 proxies with custom fingerprints.
// It uses golang.org/x/net/proxy to establish the SOCKS5 tunnel.
type SOCKS5ProxyDialer struct {
profile *Profile
proxyURL *url.URL
}
// Default TLS fingerprint values captured from Claude Code (Node.js 24.x)
// Captured via tls-fingerprint-web capture server
// JA3 Hash: 44f88fca027f27bab4bb08d4af15f23e
// JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
var (
// 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
0x1301, // TLS_AES_128_GCM_SHA256
0x1302, // TLS_AES_256_GCM_SHA384
0x1303, // TLS_CHACHA20_POLY1305_SHA256
// ECDHE + AES-GCM
0xc02b, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
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
// ECDHE + ChaCha20-Poly1305
0xcca9, // TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
0xcca8, // TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
// ECDHE + AES-CBC-SHA (legacy fallback)
0xc009, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA
0xc013, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
0xc00a, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
0xc014, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
// RSA + AES-GCM (non-PFS)
0x009c, // TLS_RSA_WITH_AES_128_GCM_SHA256
0x009d, // TLS_RSA_WITH_AES_256_GCM_SHA384
// RSA + AES-CBC-SHA (non-PFS, legacy)
0x002f, // TLS_RSA_WITH_AES_128_CBC_SHA
0x0035, // TLS_RSA_WITH_AES_256_CBC_SHA
}
// defaultCurves contains the 3 supported groups from Node.js 24.x
defaultCurves = []utls.CurveID{
utls.X25519, // 0x001d
utls.CurveP256, // 0x0017 (secp256r1)
utls.CurveP384, // 0x0018 (secp384r1)
}
// defaultPointFormats contains point formats from Node.js 24.x
defaultPointFormats = []uint16{
0, // uncompressed
}
// defaultSignatureAlgorithms contains the 9 signature algorithms from Node.js 24.x
defaultSignatureAlgorithms = []utls.SignatureScheme{
0x0403, // ecdsa_secp256r1_sha256
0x0804, // rsa_pss_rsae_sha256
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
0x0201, // rsa_pkcs1_sha1
}
)
// NewDialer creates a new TLS fingerprint dialer.
// baseDialer is used for TCP connection establishment (supports proxy scenarios).
// If baseDialer is nil, direct TCP dial is used.
func NewDialer(profile *Profile, baseDialer func(ctx context.Context, network, addr string) (net.Conn, error)) *Dialer {
if baseDialer == nil {
baseDialer = (&net.Dialer{}).DialContext
}
return &Dialer{profile: profile, baseDialer: baseDialer}
}
// NewHTTPProxyDialer creates a new TLS fingerprint dialer that works through HTTP/HTTPS proxies.
// It establishes a CONNECT tunnel before performing TLS handshake with custom fingerprint.
func NewHTTPProxyDialer(profile *Profile, proxyURL *url.URL) *HTTPProxyDialer {
return &HTTPProxyDialer{profile: profile, proxyURL: proxyURL}
}
// NewSOCKS5ProxyDialer creates a new TLS fingerprint dialer that works through SOCKS5 proxies.
// It establishes a SOCKS5 tunnel before performing TLS handshake with custom fingerprint.
func NewSOCKS5ProxyDialer(profile *Profile, proxyURL *url.URL) *SOCKS5ProxyDialer {
return &SOCKS5ProxyDialer{profile: profile, proxyURL: proxyURL}
}
// DialTLSContext establishes a TLS connection through SOCKS5 proxy with the configured fingerprint.
// Flow: SOCKS5 CONNECT to target -> TLS handshake with utls on the tunnel
func (d *SOCKS5ProxyDialer) DialTLSContext(ctx context.Context, network, addr string) (net.Conn, error) {
slog.Debug("tls_fingerprint_socks5_connecting", "proxy", d.proxyURL.Host, "target", addr)
// Step 1: Create SOCKS5 dialer
var auth *proxy.Auth
if d.proxyURL.User != nil {
username := d.proxyURL.User.Username()
password, _ := d.proxyURL.User.Password()
auth = &proxy.Auth{
User: username,
Password: password,
}
}
// Determine proxy address
proxyAddr := d.proxyURL.Host
if d.proxyURL.Port() == "" {
proxyAddr = net.JoinHostPort(d.proxyURL.Hostname(), "1080") // Default SOCKS5 port
}
socksDialer, err := proxy.SOCKS5("tcp", proxyAddr, auth, proxy.Direct)
if err != nil {
slog.Debug("tls_fingerprint_socks5_dialer_failed", "error", err)
return nil, fmt.Errorf("create SOCKS5 dialer: %w", err)
}
// Step 2: Establish SOCKS5 tunnel to target
slog.Debug("tls_fingerprint_socks5_establishing_tunnel", "target", addr)
conn, err := socksDialer.Dial("tcp", addr)
if err != nil {
slog.Debug("tls_fingerprint_socks5_connect_failed", "error", err)
return nil, fmt.Errorf("SOCKS5 connect: %w", err)
}
slog.Debug("tls_fingerprint_socks5_tunnel_established")
// Step 3: Perform TLS handshake on the tunnel with utls fingerprint
return performTLSHandshake(ctx, conn, d.profile, addr)
}
// DialTLSContext establishes a TLS connection through HTTP proxy with the configured fingerprint.
// Flow: TCP connect to proxy -> CONNECT tunnel -> TLS handshake with utls
func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr string) (net.Conn, error) {
slog.Debug("tls_fingerprint_http_proxy_connecting", "proxy", d.proxyURL.Host, "target", addr)
// Step 1: TCP connect to proxy server
var proxyAddr string
if d.proxyURL.Port() != "" {
proxyAddr = d.proxyURL.Host
} else {
// Default ports
if d.proxyURL.Scheme == "https" {
proxyAddr = net.JoinHostPort(d.proxyURL.Hostname(), "443")
} else {
proxyAddr = net.JoinHostPort(d.proxyURL.Hostname(), "80")
}
}
dialer := &net.Dialer{}
conn, err := dialer.DialContext(ctx, "tcp", proxyAddr)
if err != nil {
slog.Debug("tls_fingerprint_http_proxy_connect_failed", "error", err)
return nil, fmt.Errorf("connect to proxy: %w", err)
}
slog.Debug("tls_fingerprint_http_proxy_connected", "proxy_addr", proxyAddr)
// Step 2: Send CONNECT request to establish tunnel
req := &http.Request{
Method: "CONNECT",
URL: &url.URL{Opaque: addr},
Host: addr,
Header: make(http.Header),
}
// Add proxy authentication if present
if d.proxyURL.User != nil {
username := d.proxyURL.User.Username()
password, _ := d.proxyURL.User.Password()
auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
req.Header.Set("Proxy-Authorization", "Basic "+auth)
}
slog.Debug("tls_fingerprint_http_proxy_sending_connect", "target", addr)
if err := req.Write(conn); err != nil {
_ = conn.Close()
slog.Debug("tls_fingerprint_http_proxy_write_failed", "error", err)
return nil, fmt.Errorf("write CONNECT request: %w", err)
}
// Step 3: Read CONNECT response
br := bufio.NewReader(conn)
resp, err := http.ReadResponse(br, req)
if err != nil {
_ = conn.Close()
slog.Debug("tls_fingerprint_http_proxy_read_response_failed", "error", err)
return nil, fmt.Errorf("read CONNECT response: %w", err)
}
// 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()
slog.Debug("tls_fingerprint_http_proxy_connect_failed_status", "status_code", resp.StatusCode, "status", resp.Status)
return nil, fmt.Errorf("proxy CONNECT failed: %s", resp.Status)
}
slog.Debug("tls_fingerprint_http_proxy_tunnel_established")
// Step 4: Perform TLS handshake on the tunnel with utls fingerprint
return performTLSHandshake(ctx, conn, d.profile, addr)
}
// DialTLSContext establishes a TLS connection with the configured fingerprint.
// This method is designed to be used as http.Transport.DialTLSContext.
func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net.Conn, error) {
// Establish TCP connection using base dialer (supports proxy)
slog.Debug("tls_fingerprint_dialing_tcp", "addr", addr)
conn, err := d.baseDialer(ctx, network, addr)
if err != nil {
slog.Debug("tls_fingerprint_tcp_dial_failed", "error", err)
return nil, err
}
slog.Debug("tls_fingerprint_tcp_connected", "addr", addr)
// 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
}
spec := buildClientHelloSpecFromProfile(profile)
tlsConn := utls.UClient(conn, &utls.Config{ServerName: host}, utls.HelloCustom)
if err := tlsConn.ApplyPreset(spec); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("apply TLS preset: %w", err)
}
if err := tlsConn.HandshakeContext(ctx); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("TLS handshake failed: %w", err)
}
state := tlsConn.ConnectionState()
slog.Debug("tls_fingerprint_handshake_success",
"host", host,
"version", state.Version,
"cipher_suite", state.CipherSuite,
"alpn", state.NegotiatedProtocol)
return tlsConn, nil
}
// toUTLSCurves converts uint16 slice to utls.CurveID slice.
func toUTLSCurves(curves []uint16) []utls.CurveID {
result := make([]utls.CurveID, len(curves))
for i, c := range curves {
result[i] = utls.CurveID(c)
}
return result
}
// 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 {
// Resolve effective values (profile overrides or built-in defaults)
cipherSuites := defaultCipherSuites
if profile != nil && len(profile.CipherSuites) > 0 {
cipherSuites = profile.CipherSuites
}
curves := defaultCurves
if profile != nil && len(profile.Curves) > 0 {
curves = toUTLSCurves(profile.Curves)
}
pointFormats := defaultPointFormats
if profile != nil && len(profile.PointFormats) > 0 {
pointFormats = profile.PointFormats
}
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
// 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
}
// 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{})
}
return &utls.ClientHelloSpec{
CipherSuites: cipherSuites,
CompressionMethods: []uint8{0}, // null compression only (standard)
Extensions: extensions,
TLSVersMax: utls.VersionTLS13,
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
}
@@ -0,0 +1,368 @@
//go:build integration
package tlsfingerprint
import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
utls "github.com/refraction-networking/utls"
)
// CapturedFingerprint 对应 tls-fingerprint-web 返回的 Fingerprint 结构。
// 用于反序列化 capture server 的 JSON 响应。
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 连接 tls-fingerprint-web capture server
// 验证 Dialer 的 TLS 指纹是否匹配配置的 Profile。
//
// 该测试依赖外部服务,默认跳过。需要手动验证时设置:
// TLSFINGERPRINT_CAPTURE_URL=https://localhost:8443
//
// 运行方式:go test -tags=integration -v -run TestDialerAgainstCaptureServer ./internal/pkg/tlsfingerprint/...
func TestDialerAgainstCaptureServer(t *testing.T) {
captureURL := strings.TrimSpace(os.Getenv("TLSFINGERPRINT_CAPTURE_URL"))
if captureURL == "" {
t.Skip("跳过外部 TLS 指纹 capture 测试:未设置 TLSFINGERPRINT_CAPTURE_URL")
}
tests := []struct {
name string
profile *Profile
}{
{
name: "default_profile",
profile: &Profile{
Name: "default",
EnableGREASE: false,
// 全部留空时使用内置默认值
},
},
{
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)
// 解析实际生效的 Profile 值,也就是 Dialer 最终使用的值。
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
}
// 校验每个指纹字段
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)
}
// 校验扩展顺序;如果 Profile 显式配置了 Extensions 就使用配置值,
// 否则使用默认顺序(Node.js 24.x)。
expectedExtOrder := uint16sToInts(defaultExtensionOrder)
if len(tc.profile.Extensions) > 0 {
expectedExtOrder = uint16sToInts(tc.profile.Extensions)
}
// 比较前从期望值和采集值中剔除 GREASE。
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)
// 打印完整采集结果,便于排查指纹差异。
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")
}
@@ -0,0 +1,226 @@
//go:build integration
// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients.
//
// Integration tests for verifying TLS fingerprint correctness.
// These tests make actual network requests to external services and should be run manually.
//
// Run with: go test -v -tags=integration ./internal/pkg/tlsfingerprint/...
package tlsfingerprint
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
)
// skipIfExternalServiceUnavailable checks if the external service is available.
// If not, it skips the test instead of failing.
func skipIfExternalServiceUnavailable(t *testing.T, err error) {
t.Helper()
if err != nil {
// Check for common network/TLS errors that indicate external service issues
errStr := err.Error()
if strings.Contains(errStr, "certificate has expired") ||
strings.Contains(errStr, "certificate is not yet valid") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "broken pipe") ||
strings.Contains(errStr, "EOF") ||
strings.Contains(errStr, "no such host") ||
strings.Contains(errStr, "network is unreachable") ||
strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "deadline exceeded") {
t.Skipf("skipping test: external service unavailable: %v", err)
}
t.Fatalf("failed to get fingerprint: %v", err)
}
}
// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value.
// This test uses tls.peet.ws to verify the fingerprint.
// Expected JA3 hash: 44f88fca027f27bab4bb08d4af15f23e (Node.js 24.x)
// Expected JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
func TestJA3Fingerprint(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := &Profile{
Name: "Default Profile Test",
EnableGREASE: false,
}
dialer := NewDialer(profile, nil)
client := &http.Client{
Transport: &http.Transport{
DialTLSContext: dialer.DialTLSContext,
},
Timeout: 30 * time.Second,
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "https://tls.peet.ws/api/all", nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/24.3.0")
resp, err := client.Do(req)
skipIfExternalServiceUnavailable(t, err)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
skipIfExternalServiceUnavailable(t, err)
if resp.StatusCode != http.StatusOK {
t.Skipf("skipping test: external service unhealthy: status %d", resp.StatusCode)
}
var fpResp FingerprintResponse
if err := json.Unmarshal(body, &fpResp); err != nil {
t.Logf("Response body: %s", string(body))
t.Skipf("skipping test: external service returned unparsable response: %v", err)
}
t.Logf("JA3: %s", fpResp.TLS.JA3)
t.Logf("JA3 Hash: %s", fpResp.TLS.JA3Hash)
t.Logf("JA4: %s", fpResp.TLS.JA4)
expectedJA3Hash := "44f88fca027f27bab4bb08d4af15f23e"
if fpResp.TLS.JA3Hash == expectedJA3Hash {
t.Logf("✓ JA3 hash matches: %s", expectedJA3Hash)
} else {
t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash)
}
expectedJA4CipherHash := "_5b57614c22b0_"
if strings.Contains(fpResp.TLS.JA4, expectedJA4CipherHash) {
t.Logf("✓ JA4 cipher hash matches: %s", expectedJA4CipherHash)
} else {
t.Errorf("✗ JA4 cipher hash mismatch: got %s, expected containing %s", fpResp.TLS.JA4, expectedJA4CipherHash)
}
}
// TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws.
// Run with: go test -v -tags=integration -run TestAllProfiles ./internal/pkg/tlsfingerprint/...
func TestAllProfiles(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
// Define all profiles to test with their expected fingerprints
// These profiles are from config.yaml gateway.tls_fingerprint.profiles
profiles := []TestProfileExpectation{
{
// Default profile (Node.js 24.x)
Profile: &Profile{
Name: "default_node_v24",
EnableGREASE: false,
},
JA4CipherHash: "5b57614c22b0",
},
{
// Linux x64 Node.js v22.17.1 (explicit profile with v22 extensions)
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},
Extensions: []uint16{0, 11, 10, 35, 16, 22, 23, 13, 43, 45, 51},
},
JA4CipherHash: "a33745022dd6",
},
}
for _, tc := range profiles {
tc := tc // capture range variable
t.Run(tc.Profile.Name, func(t *testing.T) {
fp := fetchFingerprint(t, tc.Profile)
if fp == nil {
return // fetchFingerprint already called t.Fatal
}
t.Logf("Profile: %s", tc.Profile.Name)
t.Logf(" JA3: %s", fp.JA3)
t.Logf(" JA3 Hash: %s", fp.JA3Hash)
t.Logf(" JA4: %s", fp.JA4)
t.Logf(" PeetPrint: %s", fp.PeetPrint)
t.Logf(" PeetPrintHash: %s", fp.PeetPrintHash)
// Verify expectations
if tc.ExpectedJA3 != "" {
if fp.JA3Hash == tc.ExpectedJA3 {
t.Logf(" ✓ JA3 hash matches: %s", tc.ExpectedJA3)
} else {
t.Errorf(" ✗ JA3 hash mismatch: got %s, expected %s", fp.JA3Hash, tc.ExpectedJA3)
}
}
if tc.ExpectedJA4 != "" {
if fp.JA4 == tc.ExpectedJA4 {
t.Logf(" ✓ JA4 matches: %s", tc.ExpectedJA4)
} else {
t.Errorf(" ✗ JA4 mismatch: got %s, expected %s", fp.JA4, tc.ExpectedJA4)
}
}
// Check JA4 cipher hash (stable middle part)
// JA4 format: prefix_cipherHash_extHash
if tc.JA4CipherHash != "" {
if strings.Contains(fp.JA4, "_"+tc.JA4CipherHash+"_") {
t.Logf(" ✓ JA4 cipher hash matches: %s", tc.JA4CipherHash)
} else {
t.Errorf(" ✗ JA4 cipher hash mismatch: got %s, expected cipher hash %s", fp.JA4, tc.JA4CipherHash)
}
}
})
}
}
// fetchFingerprint makes a request to tls.peet.ws and returns the TLS fingerprint info.
func fetchFingerprint(t *testing.T, profile *Profile) *TLSInfo {
t.Helper()
dialer := NewDialer(profile, nil)
client := &http.Client{
Transport: &http.Transport{
DialTLSContext: dialer.DialTLSContext,
},
Timeout: 30 * time.Second,
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "https://tls.peet.ws/api/all", nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
return nil
}
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0")
resp, err := client.Do(req)
skipIfExternalServiceUnavailable(t, err)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
skipIfExternalServiceUnavailable(t, err)
if resp.StatusCode != http.StatusOK {
t.Skipf("skipping test: external service unhealthy: status %d", resp.StatusCode)
}
var fpResp FingerprintResponse
if err := json.Unmarshal(body, &fpResp); err != nil {
t.Logf("Response body: %s", string(body))
t.Skipf("skipping test: external service returned unparsable response: %v", err)
}
return &fpResp.TLS
}
@@ -0,0 +1,410 @@
//go:build unit
// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients.
//
// Unit tests for TLS fingerprint dialer.
// Integration tests that require external network are in dialer_integration_test.go
// and require the 'integration' build tag.
//
// Run unit tests: go test -v ./internal/pkg/tlsfingerprint/...
// Run integration tests: go test -v -tags=integration ./internal/pkg/tlsfingerprint/...
package tlsfingerprint
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"strings"
"testing"
"time"
)
// TestDialerBasicConnection tests that the dialer can establish TLS connections.
func TestDialerBasicConnection(t *testing.T) {
skipNetworkTest(t)
// Create a dialer with default profile
profile := &Profile{
Name: "Test Profile",
EnableGREASE: false,
}
dialer := NewDialer(profile, nil)
// Create HTTP client with custom TLS dialer
client := &http.Client{
Transport: &http.Transport{
DialTLSContext: dialer.DialTLSContext,
},
Timeout: 30 * time.Second,
}
// Make a request to a known HTTPS endpoint
resp, err := client.Get("https://www.google.com")
if err != nil {
t.Fatalf("failed to connect: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
}
// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value.
// This test uses tls.peet.ws to verify the fingerprint.
// Expected JA3 hash: 44f88fca027f27bab4bb08d4af15f23e (Node.js 24.x)
// Expected JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
func TestJA3Fingerprint(t *testing.T) {
skipNetworkTest(t)
profile := &Profile{
Name: "Default Profile Test",
EnableGREASE: false,
}
dialer := NewDialer(profile, nil)
client := &http.Client{
Transport: &http.Transport{
DialTLSContext: dialer.DialTLSContext,
},
Timeout: 30 * time.Second,
}
// Use tls.peet.ws fingerprint detection API
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "https://tls.peet.ws/api/all", nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/24.3.0")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("failed to get fingerprint: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
var fpResp FingerprintResponse
if err := json.Unmarshal(body, &fpResp); err != nil {
t.Logf("Response body: %s", string(body))
t.Fatalf("failed to parse fingerprint response: %v", err)
}
// Log all fingerprint information
t.Logf("JA3: %s", fpResp.TLS.JA3)
t.Logf("JA3 Hash: %s", fpResp.TLS.JA3Hash)
t.Logf("JA4: %s", fpResp.TLS.JA4)
t.Logf("PeetPrint: %s", fpResp.TLS.PeetPrint)
t.Logf("PeetPrint Hash: %s", fpResp.TLS.PeetPrintHash)
// Verify JA3 hash matches expected value (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 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 cipher hash mismatch: got %s, expected containing %s", fpResp.TLS.JA4, expectedJA4CipherHash)
}
// 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, 17=ciphers, 14=extensions, h1=HTTP/1.1)", expectedJA4Prefix)
} else {
altPrefix := "t13i1714h1"
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 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 (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 {
t.Logf("Warning: JA3 extension list may differ")
}
}
func skipNetworkTest(t *testing.T) {
if testing.Short() {
t.Skip("跳过网络测试(short 模式)")
}
if os.Getenv("TLSFINGERPRINT_NETWORK_TESTS") != "1" {
t.Skip("跳过网络测试(需要设置 TLSFINGERPRINT_NETWORK_TESTS=1")
}
}
// TestDialerWithProfile tests that different profiles produce different fingerprints.
func TestDialerWithProfile(t *testing.T) {
// Create two dialers with different profiles
profile1 := &Profile{
Name: "Profile 1 - No GREASE",
EnableGREASE: false,
}
profile2 := &Profile{
Name: "Profile 2 - With GREASE",
EnableGREASE: true,
}
dialer1 := NewDialer(profile1, nil)
dialer2 := NewDialer(profile2, nil)
// Build specs and compare
// Note: We can't directly compare JA3 without making network requests
// but we can verify the specs are different
spec1 := buildClientHelloSpecFromProfile(dialer1.profile)
spec2 := buildClientHelloSpecFromProfile(dialer2.profile)
// Profile with GREASE should have more extensions
if len(spec2.Extensions) <= len(spec1.Extensions) {
t.Error("expected GREASE profile to have more extensions")
}
}
// TestHTTPProxyDialerBasic tests HTTP proxy dialer creation.
// Note: This is a unit test - actual proxy testing requires a proxy server.
func TestHTTPProxyDialerBasic(t *testing.T) {
profile := &Profile{
Name: "Test Profile",
EnableGREASE: false,
}
// Test that dialer is created without panic
proxyURL := mustParseURL("http://proxy.example.com:8080")
dialer := NewHTTPProxyDialer(profile, proxyURL)
if dialer == nil {
t.Fatal("expected dialer to be created")
}
if dialer.profile != profile {
t.Error("expected profile to be set")
}
if dialer.proxyURL != proxyURL {
t.Error("expected proxyURL to be set")
}
}
// TestSOCKS5ProxyDialerBasic tests SOCKS5 proxy dialer creation.
// Note: This is a unit test - actual proxy testing requires a proxy server.
func TestSOCKS5ProxyDialerBasic(t *testing.T) {
profile := &Profile{
Name: "Test Profile",
EnableGREASE: false,
}
// Test that dialer is created without panic
proxyURL := mustParseURL("socks5://proxy.example.com:1080")
dialer := NewSOCKS5ProxyDialer(profile, proxyURL)
if dialer == nil {
t.Fatal("expected dialer to be created")
}
if dialer.profile != profile {
t.Error("expected profile to be set")
}
if dialer.proxyURL != proxyURL {
t.Error("expected proxyURL to be set")
}
}
// TestBuildClientHelloSpec tests ClientHello spec construction.
func TestBuildClientHelloSpec(t *testing.T) {
// Test with nil profile (should use defaults)
spec := buildClientHelloSpecFromProfile(nil)
if len(spec.CipherSuites) == 0 {
t.Error("expected cipher suites to be set")
}
if len(spec.Extensions) == 0 {
t.Error("expected extensions to be set")
}
// Verify default cipher suites are used
if len(spec.CipherSuites) != len(defaultCipherSuites) {
t.Errorf("expected %d cipher suites, got %d", len(defaultCipherSuites), len(spec.CipherSuites))
}
// Test with custom profile
customProfile := &Profile{
Name: "Custom",
EnableGREASE: false,
CipherSuites: []uint16{0x1301, 0x1302},
}
spec = buildClientHelloSpecFromProfile(customProfile)
if len(spec.CipherSuites) != 2 {
t.Errorf("expected 2 cipher suites, got %d", len(spec.CipherSuites))
}
}
// TestToUTLSCurves tests curve ID conversion.
func TestToUTLSCurves(t *testing.T) {
input := []uint16{0x001d, 0x0017, 0x0018}
result := toUTLSCurves(input)
if len(result) != len(input) {
t.Errorf("expected %d curves, got %d", len(input), len(result))
}
for i, curve := range result {
if uint16(curve) != input[i] {
t.Errorf("curve %d: expected 0x%04x, got 0x%04x", i, input[i], uint16(curve))
}
}
}
// Helper function to parse URL without error handling.
func mustParseURL(rawURL string) *url.URL {
u, err := url.Parse(rawURL)
if err != nil {
panic(err)
}
return u
}
// 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)
profiles := []TestProfileExpectation{
{
// Default profile (Node.js 24.x)
// JA3 Hash: 44f88fca027f27bab4bb08d4af15f23e
// JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
Profile: &Profile{
Name: "default_node_v24",
EnableGREASE: false,
},
JA4CipherHash: "5b57614c22b0",
},
{
// Linux x64 Node.js v22.17.1 (explicit profile)
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},
Extensions: []uint16{0, 11, 10, 35, 16, 22, 23, 13, 43, 45, 51},
},
JA4CipherHash: "a33745022dd6",
},
}
for _, tc := range profiles {
tc := tc // capture range variable
t.Run(tc.Profile.Name, func(t *testing.T) {
fp := fetchFingerprint(t, tc.Profile)
if fp == nil {
return // fetchFingerprint already called t.Fatal
}
t.Logf("Profile: %s", tc.Profile.Name)
t.Logf(" JA3: %s", fp.JA3)
t.Logf(" JA3 Hash: %s", fp.JA3Hash)
t.Logf(" JA4: %s", fp.JA4)
t.Logf(" PeetPrint: %s", fp.PeetPrint)
t.Logf(" PeetPrintHash: %s", fp.PeetPrintHash)
// Verify expectations
if tc.ExpectedJA3 != "" {
if fp.JA3Hash == tc.ExpectedJA3 {
t.Logf(" ✓ JA3 hash matches: %s", tc.ExpectedJA3)
} else {
t.Errorf(" ✗ JA3 hash mismatch: got %s, expected %s", fp.JA3Hash, tc.ExpectedJA3)
}
}
if tc.ExpectedJA4 != "" {
if fp.JA4 == tc.ExpectedJA4 {
t.Logf(" ✓ JA4 matches: %s", tc.ExpectedJA4)
} else {
t.Errorf(" ✗ JA4 mismatch: got %s, expected %s", fp.JA4, tc.ExpectedJA4)
}
}
// Check JA4 cipher hash (stable middle part)
// JA4 format: prefix_cipherHash_extHash
if tc.JA4CipherHash != "" {
if strings.Contains(fp.JA4, "_"+tc.JA4CipherHash+"_") {
t.Logf(" ✓ JA4 cipher hash matches: %s", tc.JA4CipherHash)
} else {
t.Errorf(" ✗ JA4 cipher hash mismatch: got %s, expected cipher hash %s", fp.JA4, tc.JA4CipherHash)
}
}
})
}
}
// fetchFingerprint makes a request to tls.peet.ws and returns the TLS fingerprint info.
func fetchFingerprint(t *testing.T, profile *Profile) *TLSInfo {
t.Helper()
dialer := NewDialer(profile, nil)
client := &http.Client{
Transport: &http.Transport{
DialTLSContext: dialer.DialTLSContext,
},
Timeout: 30 * time.Second,
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "https://tls.peet.ws/api/all", nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
return nil
}
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("failed to get fingerprint: %v", err)
return nil
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response: %v", err)
return nil
}
var fpResp FingerprintResponse
if err := json.Unmarshal(body, &fpResp); err != nil {
t.Logf("Response body: %s", string(body))
t.Fatalf("failed to parse fingerprint response: %v", err)
return nil
}
return &fpResp.TLS
}
@@ -0,0 +1,28 @@
package tlsfingerprint
// FingerprintResponse represents the response from tls.peet.ws/api/all.
// 共享测试类型,供 unit 和 integration 测试文件使用。
type FingerprintResponse struct {
IP string `json:"ip"`
TLS TLSInfo `json:"tls"`
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"`
JA3Hash string `json:"ja3_hash"`
JA4 string `json:"ja4"`
PeetPrint string `json:"peetprint"`
PeetPrintHash string `json:"peetprint_hash"`
ClientRandom string `json:"client_random"`
SessionID string `json:"session_id"`
}