369 lines
13 KiB
Go
369 lines
13 KiB
Go
//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")
|
||
}
|