From d902862d7784b98bdde948736f24044a8fcfce2f Mon Sep 17 00:00:00 2001 From: MsfPablo <129399053+MsfPablo@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:51:30 +0200 Subject: [PATCH] feat(checks): add proxy protocol fingerprint check (#52) Add a protocol-category check that auto-detects which proxy protocol a server actually speaks by probing it with SOCKS5, SOCKS4 and HTTP CONNECT greetings on separate connections, then validates the result against the user-configured --proxy-type. - SOCKS5 probe sends a no-auth method negotiation (05 01 00) and matches on a 0x05 version reply. - SOCKS4 probe sends a CONNECT for a fixed target and matches on the 0x00 reply version byte (distinct from the 0x04 request version). - HTTP CONNECT probe matches on an "HTTP/" reply prefix. - evaluateFingerprint is a pure helper: it reports the detected protocol, passes when declared matches (with https satisfying http detection, since both are HTTP CONNECT at the application layer), fails with a concrete --proxy-type suggestion on mismatch, errors when no greeting is answered, and handles auto-detection. Registered in core/checks/register.go and added to the README checks table. Tests use a hermetic net.Listener mock that replies per protocol dialect; no external network is required. --- README.md | 1 + core/checks/proxy_fingerprint/check.go | 345 ++++++++++++++++++++ core/checks/proxy_fingerprint/check_test.go | 234 +++++++++++++ core/checks/register.go | 2 + 4 files changed, 582 insertions(+) create mode 100644 core/checks/proxy_fingerprint/check.go create mode 100644 core/checks/proxy_fingerprint/check_test.go diff --git a/README.md b/README.md index 3069be9..ac5cccb 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ Every check tells you **what it tests** and **what service it uses**. | `dns_leak` | Compares DNS through proxy vs direct path | System DNS on both adapter paths | | `webrtc_leak` | Detects if STUN/ICE could leak the real IP | STUN probes to Google, Twilio, and Viagenie servers via UDP | | `header_leak` | Detects if forwarded headers leak the real client IP or internal network metadata | [httpbin.org/headers](https://httpbin.org/headers), [httpbin.org/ip](https://httpbin.org/ip) | +| `proxy_fingerprint` | Auto-detects the proxy protocol and validates it against the configured type | Probes the proxy with SOCKS5, SOCKS4 and HTTP CONNECT greetings | ### Plugin Checks diff --git a/core/checks/proxy_fingerprint/check.go b/core/checks/proxy_fingerprint/check.go new file mode 100644 index 0000000..bbd0e82 --- /dev/null +++ b/core/checks/proxy_fingerprint/check.go @@ -0,0 +1,345 @@ +package proxyfingerprint + +import ( + "fmt" + "io" + "net" + "strings" + "time" + + "github.com/francomano/proxydoctor/core/check" +) + +// probeDialer is the minimal dial surface the fingerprint probes need. It is an +// interface so the unit tests can inject a hermetic listener without touching +// real network endpoints. +type probeDialer interface { + DialTimeout(network, address string, timeout time.Duration) (net.Conn, error) +} + +// stdDialer adapts the net package to probeDialer. +type stdDialer struct{} + +func (stdDialer) DialTimeout(network, address string, timeout time.Duration) (net.Conn, error) { + return net.DialTimeout(network, address, timeout) +} + +// probeTimeout is the per-handshake deadline. Fingerprinting opens one fresh +// connection per protocol so a misbehaving peer cannot starve the others. +const probeTimeout = 5 * time.Second + +// fingerprintTarget is the host:port used inside the SOCKS4/HTTP-CONNECT probe +// payloads. The proxy is only asked to *attempt* a connect; whether it reaches +// the target is irrelevant — we classify by how it answers the greeting. +const fingerprintTarget = "1.1.1.1:53" + +// ProxyFingerprintCheck auto-detects which proxy protocol a server actually +// speaks by probing it with SOCKS5, SOCKS4 and HTTP CONNECT greetings, then +// validates that against the user-configured proxy type. +type ProxyFingerprintCheck struct { + dialer probeDialer +} + +// NewProxyFingerprintCheck creates a new proxy protocol fingerprint check. +func NewProxyFingerprintCheck() check.Checker { + return &ProxyFingerprintCheck{dialer: stdDialer{}} +} + +func (c *ProxyFingerprintCheck) ID() string { return "proxy_fingerprint" } + +func (c *ProxyFingerprintCheck) Name() string { return "Proxy Protocol Fingerprint" } + +func (c *ProxyFingerprintCheck) Description() string { + return "Auto-detects the proxy protocol (SOCKS5, SOCKS4 or HTTP CONNECT) by probing the server and validates that it matches the configured proxy type, suggesting the correct type on mismatch" +} + +func (c *ProxyFingerprintCheck) Category() check.CheckCategory { return check.CategoryProtocol } + +func (c *ProxyFingerprintCheck) DependsOn() []string { return []string{} } + +func (c *ProxyFingerprintCheck) Execute(ctx check.ExecutionContext) check.CheckResult { + result := check.NewCheckResult(c.ID(), c.Category()) + startTime := time.Now() + + cfg := ctx.GetProxyConfig() + if cfg.Type == check.ProxyTypeDirect { + result.SetExecutionTime(time.Since(startTime)) + return *result.WithStatus(check.StatusSkipped, check.SeverityInfo). + WithExplanation("No proxy is configured; protocol fingerprinting only applies to a configured proxy endpoint"). + WithConfidence(0) + } + + address := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + result.AddEvidence("proxy_endpoint", address) + result.AddEvidence("declared_type", string(cfg.Type)) + + // Use the injected dialer (tests override it); fall back to the real net + // dialer when the check was constructed via NewProxyFingerprintCheck. + dialer := c.dialer + if dialer == nil { + dialer = stdDialer{} + } + + probes := map[string]func(string, probeDialer) probeResult{ + "socks5": probeSOCKS5, + "socks4": probeSOCKS4, + "http": probeHTTPConnect, + } + + var detected []string + details := make(map[string]string, len(probes)) + for proto, fn := range probes { + pr := fn(address, dialer) + result.AddEvidence(proto+"_probe", pr.detail) + if pr.matched { + detected = append(detected, proto) + } + } + + verdict := evaluateFingerprint(detected, string(cfg.Type), details) + result.SetExecutionTime(time.Since(startTime)) + + result.WithStatus(verdict.status, verdict.severity). + WithExplanation(verdict.explanation). + WithConfidence(verdict.confidence) + for _, cause := range verdict.causes { + result.AddProbableCause(cause) + } + for _, action := range verdict.actions { + result.AddSuggestedAction(action) + } + return *result +} + +// probeResult is the outcome of a single protocol greeting. +type probeResult struct { + matched bool + detail string +} + +// probeSOCKS5 sends a no-auth method negotiation (VER=5, NMETHODS=1, 0x00) and +// classifies the peer as SOCKS5 iff the reply's version byte is 0x05. +func probeSOCKS5(address string, dialer probeDialer) probeResult { + conn, err := dialer.DialTimeout("tcp", address, probeTimeout) + if err != nil { + return probeResult{detail: fmt.Sprintf("dial: %v", err)} + } + defer conn.Close() + + if _, err := conn.Write([]byte{0x05, 0x01, 0x00}); err != nil { + return probeResult{detail: fmt.Sprintf("greeting write: %v", err)} + } + + resp := make([]byte, 2) + if _, err := io.ReadFull(conn, resp); err != nil { + return probeResult{detail: fmt.Sprintf("greeting read: %v", err)} + } + if resp[0] != 0x05 { + return probeResult{detail: fmt.Sprintf("reply version byte 0x%02x (want 0x05)", resp[0])} + } + switch resp[1] { + case 0x00: + return probeResult{matched: true, detail: "no-auth accepted (05 00)"} + case 0x02: + return probeResult{matched: true, detail: "username/password required (05 02)"} + case 0xFF: + return probeResult{matched: true, detail: "no acceptable method (05 ff)"} + default: + return probeResult{matched: true, detail: fmt.Sprintf("method 0x%02x", resp[1])} + } +} + +// probeSOCKS4 sends a CONNECT request for fingerprintTarget and classifies the +// peer as SOCKS4 iff the 8-byte reply's version byte is 0x00 (the SOCKS4 reply +// version, distinct from the request version 0x04). +func probeSOCKS4(address string, dialer probeDialer) probeResult { + conn, err := dialer.DialTimeout("tcp", address, probeTimeout) + if err != nil { + return probeResult{detail: fmt.Sprintf("dial: %v", err)} + } + defer conn.Close() + + host, port, ok := splitHostPort(fingerprintTarget) + if !ok { + return probeResult{detail: "internal: bad fingerprint target"} + } + ip := net.ParseIP(host).To4() + if ip == nil { + return probeResult{detail: "internal: fingerprint target must be IPv4"} + } + + // VER=4, CMD=1 (CONNECT), port, IP, userid (empty, null-terminated). + req := []byte{0x04, 0x01, byte(port >> 8), byte(port), ip[0], ip[1], ip[2], ip[3], 0x00} + if _, err := conn.Write(req); err != nil { + return probeResult{detail: fmt.Sprintf("request write: %v", err)} + } + + resp := make([]byte, 8) + if _, err := io.ReadFull(conn, resp); err != nil { + return probeResult{detail: fmt.Sprintf("reply read: %v", err)} + } + if resp[0] != 0x00 { + return probeResult{detail: fmt.Sprintf("reply version byte 0x%02x (want 0x00)", resp[0])} + } + switch resp[1] { + case 0x5A: + return probeResult{matched: true, detail: "request granted (00 5a)"} + case 0x5B: + return probeResult{matched: true, detail: "request rejected or failed (00 5b)"} + case 0x5C: + return probeResult{matched: true, detail: "identd unavailable (00 5c)"} + case 0x5D: + return probeResult{matched: true, detail: "identd mismatch (00 5d)"} + default: + return probeResult{matched: true, detail: fmt.Sprintf("status 0x%02x", resp[1])} + } +} + +// probeHTTPConnect sends an HTTP/1.1 CONNECT for fingerprintTarget and +// classifies the peer as an HTTP proxy iff the reply begins with "HTTP/". +func probeHTTPConnect(address string, dialer probeDialer) probeResult { + conn, err := dialer.DialTimeout("tcp", address, probeTimeout) + if err != nil { + return probeResult{detail: fmt.Sprintf("dial: %v", err)} + } + defer conn.Close() + + req := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\nUser-Agent: ProxyDoctor-fingerprint\r\n\r\n", fingerprintTarget, fingerprintTarget) + if _, err := conn.Write([]byte(req)); err != nil { + return probeResult{detail: fmt.Sprintf("request write: %v", err)} + } + + buf := make([]byte, 64) + n, err := conn.Read(buf) + if err != nil && n == 0 { + return probeResult{detail: fmt.Sprintf("reply read: %v", err)} + } + line := strings.TrimSpace(string(buf[:n])) + if strings.HasPrefix(strings.ToUpper(line), "HTTP/") { + return probeResult{matched: true, detail: firstLine(line)} + } + return probeResult{detail: fmt.Sprintf("non-HTTP reply: %q", truncate(line, 48))} +} + +// fingerprintVerdict is the pure, testable outcome of the fingerprint decision. +type fingerprintVerdict struct { + status check.Status + severity check.Severity + explanation string + confidence float64 + causes []string + actions []string +} + +// evaluateFingerprint decides the check verdict from the detected protocols +// and the user-declared type. detected lists every protocol that answered its +// greeting correctly (normally exactly one). details maps protocol -> probe +// detail for evidence already recorded by the caller. +func evaluateFingerprint(detected []string, declared string, details map[string]string) fingerprintVerdict { + declared = strings.TrimSpace(strings.ToLower(declared)) + + if len(detected) == 0 { + return fingerprintVerdict{ + status: check.StatusError, + severity: check.SeverityWarning, + explanation: "The proxy endpoint did not answer any of the SOCKS5, SOCKS4 or HTTP CONNECT greetings; it may be offline, require TLS (an https:// proxy), or speak an unsupported protocol", + confidence: 0.6, + causes: []string{ + "The proxy host:port is unreachable or not listening", + "The proxy expects TLS (configure it as https://) and rejects plaintext handshakes", + "The proxy speaks a protocol ProxyDoctor cannot fingerprint", + }, + actions: []string{ + "Verify the proxy address and port with `proxydoctor diagnose --checks port_connectivity`", + "If the proxy is reached over TLS, configure it with --proxy-type https", + }, + } + } + + // Normalise: an HTTP CONNECT reply is consistent with both an http and an + // https declared proxy, since https is the same application-layer protocol + // over TLS — the plaintext probe cannot distinguish them. + primary := detected[0] + if len(detected) > 1 { + // Ambiguous but informative: report what spoke. + return fingerprintVerdict{ + status: check.StatusFailed, + severity: check.SeverityWarning, + explanation: fmt.Sprintf("The proxy answered more than one greeting (%s); it may be a multiprotocol proxy. Configure it as %s", strings.Join(detected, ", "), primary), + confidence: 0.7, + actions: []string{fmt.Sprintf("Use --proxy-type %s to match the detected protocol", primary)}, + } + } + + if declared == "" || declared == "auto" { + return fingerprintVerdict{ + status: check.StatusPassed, + severity: check.SeverityInfo, + explanation: fmt.Sprintf("Auto-detection configured; the proxy speaks %s", primary), + confidence: 0.85, + actions: []string{fmt.Sprintf("Pin the type with --proxy-type %s to skip detection on future runs", primary)}, + } + } + + if typeMatches(declared, primary) { + return fingerprintVerdict{ + status: check.StatusPassed, + severity: check.SeverityInfo, + explanation: fmt.Sprintf("The proxy speaks %s, matching the configured type %q", primary, declared), + confidence: 0.9, + } + } + + return fingerprintVerdict{ + status: check.StatusFailed, + severity: check.SeverityWarning, + explanation: fmt.Sprintf("Configured proxy type %q does not match the detected protocol %s; the proxy speaks %s", declared, primary, primary), + confidence: 0.85, + causes: []string{ + fmt.Sprintf("The proxy endpoint answers the %s greeting but not the %s greeting", primary, declared), + "The --proxy-type flag (or proxy URL scheme) was set to a protocol the server does not speak", + }, + actions: []string{ + fmt.Sprintf("Reconfigure with --proxy-type %s to match the detected protocol", primary), + }, + } +} + +// typeMatches reconciles the declared proxy type with the detected protocol, +// allowing https-declared proxies to satisfy an http detection (same +// application layer over TLS). +func typeMatches(declared, detected string) bool { + if declared == detected { + return true + } + if declared == string(check.ProxyTypeHTTPS) && detected == string(check.ProxyTypeHTTP) { + return true + } + return false +} + +func splitHostPort(s string) (string, int, bool) { + host, portStr, err := net.SplitHostPort(s) + if err != nil { + return "", 0, false + } + port, err := net.LookupPort("tcp", portStr) + if err != nil { + return "", 0, false + } + return host, port, true +} + +func firstLine(s string) string { + if i := strings.IndexAny(s, "\r\n"); i >= 0 { + return s[:i] + } + return s +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} \ No newline at end of file diff --git a/core/checks/proxy_fingerprint/check_test.go b/core/checks/proxy_fingerprint/check_test.go new file mode 100644 index 0000000..9192df6 --- /dev/null +++ b/core/checks/proxy_fingerprint/check_test.go @@ -0,0 +1,234 @@ +package proxyfingerprint + +import ( + "io" + "net" + "strings" + "sync" + "testing" + "time" + + "github.com/francomano/proxydoctor/core/check" +) + +// startMockProxy launches a TCP listener that classifies each inbound +// connection by the first byte it receives and replies with the matching +// protocol greeting. dialect selects which protocol the mock "speaks": +// "socks5", "socks4", "http". The returned address is the dial target. +func startMockProxy(t *testing.T, dialect string) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + var wg sync.WaitGroup + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + wg.Add(1) + go func(c net.Conn) { + defer wg.Done() + defer c.Close() + handleMock(c, dialect) + }(conn) + } + }() + t.Cleanup(func() { ln.Close(); wg.Wait() }) + + return ln.Addr().String() +} + +func handleMock(c net.Conn, dialect string) { + // Read the first byte to inspect which probe is being sent, so a single + // mock endpoint can service all three probes and reply consistently with + // its dialect. + buf := make([]byte, 1) + if _, err := io.ReadFull(c, buf); err != nil { + return + } + switch dialect { + case "socks5": + // Reply 05 00 regardless of the probe byte: only a real SOCKS5 probe + // (which sent 05 01 00) treats 0x05 as a version match. SOCKS4 and + // HTTP probes read a 2-byte/line reply that will not match their + // classifiers, so only the SOCKS5 probe records a match. + _, _ = c.Write([]byte{0x05, 0x00}) + case "socks4": + // Reply 00 5a + 6 padding bytes; SOCKS4 probe checks resp[0]==0x00. + _, _ = c.Write([]byte{0x00, 0x5a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}) + case "http": + // Reply with an HTTP status line; HTTP probe checks "HTTP/" prefix. + _, _ = c.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")) + } +} + +func runCheck(t *testing.T, c *ProxyFingerprintCheck, proxyType check.ProxyType, addr string) check.CheckResult { + t.Helper() + host, port, _ := net.SplitHostPort(addr) + ctx := &fakeCtx{cfg: check.ProxyConfig{Type: proxyType, Host: host, Port: parsePort(port)}} + return c.Execute(ctx) +} + +func parsePort(s string) int { + p, _ := net.LookupPort("tcp", s) + return p +} + +type fakeCtx struct { + cfg check.ProxyConfig +} + +func (f *fakeCtx) GetURL() string { return "" } +func (f *fakeCtx) GetProxyConfig() check.ProxyConfig { return f.cfg } +func (f *fakeCtx) GetDirectAdapter() check.NetworkAdapter { return nil } +func (f *fakeCtx) GetProxyAdapter() check.NetworkAdapter { return nil } +func (f *fakeCtx) GetSharedData(key string) interface{} { return nil } +func (f *fakeCtx) SetSharedData(key string, value interface{}) {} +func (f *fakeCtx) GetTimeout() time.Duration { return 5 * time.Second } +func (f *fakeCtx) IsCancelled() bool { return false } + +func TestSkipDirect(t *testing.T) { + c := &ProxyFingerprintCheck{dialer: stdDialer{}} + ctx := &fakeCtx{cfg: check.ProxyConfig{Type: check.ProxyTypeDirect}} + r := c.Execute(ctx) + if r.Status != check.StatusSkipped { + t.Fatalf("direct connection should skip, got %s", r.Status) + } +} + +func TestDetectSOCKS5Matches(t *testing.T) { + addr := startMockProxy(t, "socks5") + c := &ProxyFingerprintCheck{dialer: stdDialer{}} + r := runCheck(t, c, check.ProxyTypeSOCKS5, addr) + if r.Status != check.StatusPassed { + t.Fatalf("socks5 proxy with declared socks5 should pass, got %s: %s", r.Status, r.Explanation) + } +} + +func TestDetectSOCKS4Matches(t *testing.T) { + addr := startMockProxy(t, "socks4") + c := &ProxyFingerprintCheck{dialer: stdDialer{}} + r := runCheck(t, c, check.ProxyTypeSOCKS4, addr) + if r.Status != check.StatusPassed { + t.Fatalf("socks4 proxy with declared socks4 should pass, got %s: %s", r.Status, r.Explanation) + } +} + +func TestDetectHTTPMatches(t *testing.T) { + addr := startMockProxy(t, "http") + c := &ProxyFingerprintCheck{dialer: stdDialer{}} + r := runCheck(t, c, check.ProxyTypeHTTP, addr) + if r.Status != check.StatusPassed { + t.Fatalf("http proxy with declared http should pass, got %s: %s", r.Status, r.Explanation) + } +} + +func TestHTTPSDeclaredSatisfiesHTTPDetected(t *testing.T) { + addr := startMockProxy(t, "http") + c := &ProxyFingerprintCheck{dialer: stdDialer{}} + r := runCheck(t, c, check.ProxyTypeHTTPS, addr) + if r.Status != check.StatusPassed { + t.Fatalf("https-declared proxy that answers plaintext CONNECT should pass, got %s: %s", r.Status, r.Explanation) + } +} + +func TestMismatchSuggestsDetected(t *testing.T) { + addr := startMockProxy(t, "socks5") + c := &ProxyFingerprintCheck{dialer: stdDialer{}} + r := runCheck(t, c, check.ProxyTypeHTTP, addr) + if r.Status != check.StatusFailed { + t.Fatalf("declared http over a socks5 proxy should fail, got %s", r.Status) + } + if !strings.Contains(r.Explanation, "socks5") { + t.Errorf("explanation should name the detected protocol: %q", r.Explanation) + } + foundSuggestion := false + for _, a := range r.SuggestedActions { + if strings.Contains(a, "socks5") { + foundSuggestion = true + } + } + if !foundSuggestion { + t.Errorf("expected a suggested action recommending socks5, got %v", r.SuggestedActions) + } +} + +func TestAutoDeclaresDetected(t *testing.T) { + addr := startMockProxy(t, "socks4") + c := &ProxyFingerprintCheck{dialer: stdDialer{}} + r := runCheck(t, c, "auto", addr) + if r.Status != check.StatusPassed { + t.Fatalf("auto-detection should pass when a protocol is identified, got %s", r.Status) + } + if !strings.Contains(r.Explanation, "socks4") { + t.Errorf("auto verdict should report the detected protocol: %q", r.Explanation) + } +} + +func TestUnreachableEndpointErrors(t *testing.T) { + // Pick a port that is almost certainly closed. + ln, _ := net.Listen("tcp", "127.0.0.1:0") + addr := ln.Addr().String() + ln.Close() + + c := &ProxyFingerprintCheck{dialer: stdDialer{}} + r := runCheck(t, c, check.ProxyTypeSOCKS5, addr) + if r.Status != check.StatusError { + t.Fatalf("unreachable proxy should yield error, got %s: %s", r.Status, r.Explanation) + } +} + +// --- pure verdict tests (no network) --- + +func TestEvaluateNoDetectionErrors(t *testing.T) { + v := evaluateFingerprint(nil, "socks5", nil) + if v.status != check.StatusError { + t.Fatalf("expected error, got %s", v.status) + } +} + +func TestEvaluateAutoPasses(t *testing.T) { + v := evaluateFingerprint([]string{"http"}, "auto", nil) + if v.status != check.StatusPassed { + t.Fatalf("expected passed, got %s", v.status) + } +} + +func TestEvaluateMismatchFails(t *testing.T) { + v := evaluateFingerprint([]string{"socks5"}, "socks4", nil) + if v.status != check.StatusFailed { + t.Fatalf("expected failed, got %s", v.status) + } + if !strings.Contains(v.explanation, "socks5") { + t.Errorf("explanation should name detected protocol: %q", v.explanation) + } +} + +func TestEvaluateHTTPSSHTTPSMatch(t *testing.T) { + if !typeMatches("https", "http") { + t.Fatal("https-declared should match http-detected") + } + if typeMatches("socks4", "socks5") { + t.Fatal("socks4/socks5 should not match") + } +} + +func TestEvaluateAmbiguousFails(t *testing.T) { + v := evaluateFingerprint([]string{"socks5", "http"}, "socks5", nil) + if v.status != check.StatusFailed { + t.Fatalf("expected failed for multiprotocol, got %s", v.status) + } +} + +// ensure the check still completes promptly; the hermetic mock makes this a +// fast test, but a regression that drops the per-probe deadline would hang. +func TestProbeTimeoutIsBounded(t *testing.T) { + if probeTimeout > 10*time.Second { + t.Fatalf("probeTimeout grew too large: %v", probeTimeout) + } +} \ No newline at end of file diff --git a/core/checks/register.go b/core/checks/register.go index b21a364..5d50c94 100644 --- a/core/checks/register.go +++ b/core/checks/register.go @@ -9,6 +9,7 @@ import ( headerleak "github.com/francomano/proxydoctor/core/checks/header_leak" ipv6leak "github.com/francomano/proxydoctor/core/checks/ipv6_leak" portscan "github.com/francomano/proxydoctor/core/checks/port_scan" + proxyfingerprint "github.com/francomano/proxydoctor/core/checks/proxy_fingerprint" publicip "github.com/francomano/proxydoctor/core/checks/public_ip" tlscert "github.com/francomano/proxydoctor/core/checks/tls_cert" webrtcleak "github.com/francomano/proxydoctor/core/checks/webrtc_leak" @@ -26,6 +27,7 @@ func RegisterDefaults(registry *engine.CheckRegistry) error { dnsleak.NewDNSLeakCheck(), webrtcleak.NewWebRTCLeakCheck(), headerleak.NewHeaderLeakCheck(), + proxyfingerprint.NewProxyFingerprintCheck(), } for _, checker := range defaults { if err := registry.Register(checker); err != nil {