From 0f5d80135643063fb1307506582612f276109b76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E8=BE=89?= Date: Sat, 12 Sep 2026 22:53:34 +0800 Subject: [PATCH 1/6] fix(management): use Chrome TLS for ChatGPT api-call Route https://chatgpt.com through the existing Codex Chrome uTLS transport so management APICall can fetch backend-api JSON instead of Cloudflare bot-challenge HTML. Other hosts keep the standard transport and proxy priority is unchanged. --- internal/api/handlers/management/api_tools.go | 96 +++++- .../api/handlers/management/api_tools_test.go | 277 ++++++++++++++++++ .../runtime/executor/helps/utls_client.go | 7 + .../executor/helps/utls_client_test.go | 13 + 4 files changed, 385 insertions(+), 8 deletions(-) diff --git a/internal/api/handlers/management/api_tools.go b/internal/api/handlers/management/api_tools.go index 039db21d37c..b44966467fc 100644 --- a/internal/api/handlers/management/api_tools.go +++ b/internal/api/handlers/management/api_tools.go @@ -14,6 +14,7 @@ import ( "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil" log "github.com/sirupsen/logrus" @@ -21,6 +22,17 @@ import ( const defaultAPICallTimeout = 60 * time.Second +// chromeAPICallUserAgent matches the ChatGPT web client used by the proven +// subscriptions fetch path. Applied only when the caller omitted User-Agent +// for chatgpt.com so Go's default "Go-http-client" UA is not sent. +const chromeAPICallUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + +// newChromeAPICallTransport builds the Chrome-impersonating transport for +// chatgpt.com. Tests replace this to avoid live TLS handshakes. +var newChromeAPICallTransport = func(proxyURL string) http.RoundTripper { + return helps.NewChromeRoundTripper(proxyURL) +} + const ( antigravityOAuthClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" antigravityOAuthClientSecret = "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" @@ -82,6 +94,11 @@ type apiCallResponse struct { // 3. Global config proxy-url // 4. Direct connect (environment proxies are not used) // +// ChatGPT (https://chatgpt.com) uses the same Chrome TLS/HTTP2 fingerprint as +// Codex. Callers may send ChatGPT web headers (User-Agent, oai-language, +// x-openai-target-path / x-openai-target-route); missing ones are filled only +// for that host. Other hosts keep the standard transport. +// // Response JSON (returned with HTTP 200 when the APICall itself succeeds): // - status_code: Upstream HTTP status code. // - header: Upstream response headers. @@ -207,11 +224,12 @@ func (h *Handler) APICall(c *gin.Context) { if hostOverride != "" { req.Host = hostOverride } + applyChatGPTAPICallHeaderDefaults(req) httpClient := &http.Client{ - Timeout: defaultAPICallTimeout, + Timeout: defaultAPICallTimeout, + Transport: h.apiCallClientTransport(auth, requestProxyURL), } - httpClient.Transport = h.apiCallTransport(auth, requestProxyURL) resp, errDo := httpClient.Do(req) if errDo != nil { @@ -593,14 +611,30 @@ func (h *Handler) authByIndex(authIndex string) *coreauth.Auth { return nil } -func (h *Handler) apiCallTransport(auth *coreauth.Auth, requestProxyURL string) http.RoundTripper { +func (h *Handler) apiCallClientTransport(auth *coreauth.Auth, requestProxyURL string) http.RoundTripper { + return &apiCallRoundTripper{ + chrome: newChromeAPICallTransport(h.apiCallProxyURL(auth, requestProxyURL)), + fallback: h.apiCallTransport(auth, requestProxyURL), + } +} + +func (h *Handler) apiCallProxyURL(auth *coreauth.Auth, requestProxyURL string) string { if proxyStr := strings.TrimSpace(requestProxyURL); proxyStr != "" { - if transport := buildProxyTransport(proxyStr); transport != nil { - return transport + if buildProxyTransport(proxyStr) != nil { + return proxyStr } - return directAPICallTransport() + return "direct" } + for _, proxyStr := range h.apiCallProxyCandidates(auth) { + if buildProxyTransport(proxyStr) != nil { + return proxyStr + } + } + return "" +} + +func (h *Handler) apiCallProxyCandidates(auth *coreauth.Auth) []string { var proxyCandidates []string if auth != nil { if proxyStr := strings.TrimSpace(auth.ProxyURL); proxyStr != "" { @@ -617,16 +651,62 @@ func (h *Handler) apiCallTransport(auth *coreauth.Auth, requestProxyURL string) proxyCandidates = append(proxyCandidates, proxyStr) } } + return proxyCandidates +} - for _, proxyStr := range proxyCandidates { +func (h *Handler) apiCallTransport(auth *coreauth.Auth, requestProxyURL string) http.RoundTripper { + if proxyStr := h.apiCallProxyURL(auth, requestProxyURL); proxyStr != "" { if transport := buildProxyTransport(proxyStr); transport != nil { return transport } } - return directAPICallTransport() } +type apiCallRoundTripper struct { + chrome http.RoundTripper + fallback http.RoundTripper +} + +func (t *apiCallRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if t == nil { + return nil, fmt.Errorf("api-call transport is nil") + } + if usesChromeAPICallTLS(req.URL) && t.chrome != nil { + return t.chrome.RoundTrip(req) + } + if t.fallback == nil { + return nil, fmt.Errorf("api-call fallback transport is nil") + } + return t.fallback.RoundTrip(req) +} + +func usesChromeAPICallTLS(u *url.URL) bool { + return u != nil && u.Scheme == "https" && strings.EqualFold(u.Hostname(), "chatgpt.com") +} + +func applyChatGPTAPICallHeaderDefaults(req *http.Request) { + if req == nil || !usesChromeAPICallTLS(req.URL) { + return + } + setHeaderDefault := func(key, value string) { + if strings.TrimSpace(req.Header.Get(key)) == "" { + req.Header.Set(key, value) + } + } + path := req.URL.EscapedPath() + if path == "" { + path = "/" + } + setHeaderDefault("Accept", "*/*") + setHeaderDefault("Accept-Language", "en-US,en;q=0.9") + setHeaderDefault("OAI-Language", "en-US") + setHeaderDefault("Referer", "https://chatgpt.com/") + setHeaderDefault("User-Agent", chromeAPICallUserAgent) + setHeaderDefault("X-OpenAI-Target-Path", path) + setHeaderDefault("X-OpenAI-Target-Route", path) +} + func directAPICallTransport() http.RoundTripper { transport, ok := http.DefaultTransport.(*http.Transport) if !ok || transport == nil { diff --git a/internal/api/handlers/management/api_tools_test.go b/internal/api/handlers/management/api_tools_test.go index 56c86a77f24..c4ca36591a0 100644 --- a/internal/api/handlers/management/api_tools_test.go +++ b/internal/api/handlers/management/api_tools_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "strings" "sync" "sync/atomic" @@ -19,6 +20,26 @@ import ( sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) +type apiCallRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f apiCallRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func stubChromeAPICallTransport(t *testing.T, proxy *string, trip apiCallRoundTripFunc) { + t.Helper() + original := newChromeAPICallTransport + t.Cleanup(func() { + newChromeAPICallTransport = original + }) + newChromeAPICallTransport = func(proxyURL string) http.RoundTripper { + if proxy != nil { + *proxy = proxyURL + } + return trip + } +} + func TestAPICallUsesRequestProxyURL(t *testing.T) { t.Parallel() @@ -652,3 +673,259 @@ func TestResolveMetaTokenUsesRequestProxyWithoutSavingOverride(t *testing.T) { t.Fatal("request proxy override changed the credential's configured proxy") } } + +func TestUsesChromeAPICallTLS(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + raw string + want bool + }{ + {name: "chatgpt backend-api", raw: "https://chatgpt.com/backend-api/subscriptions", want: true}, + {name: "chatgpt mixed case host", raw: "https://ChatGPT.com/backend-api/codex/responses", want: true}, + {name: "chatgpt http", raw: "http://chatgpt.com/backend-api/subscriptions", want: false}, + {name: "lookalike host", raw: "https://chatgpt.com.example/backend-api/subscriptions", want: false}, + {name: "other https", raw: "https://api.example.com/v1/ping", want: false}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + parsed, errParse := url.Parse(tc.raw) + if errParse != nil { + t.Fatalf("parse url: %v", errParse) + } + if got := usesChromeAPICallTLS(parsed); got != tc.want { + t.Fatalf("usesChromeAPICallTLS(%q) = %v, want %v", tc.raw, got, tc.want) + } + }) + } +} + +func TestApplyChatGPTAPICallHeaderDefaults(t *testing.T) { + t.Parallel() + + t.Run("fills missing chatgpt headers", func(t *testing.T) { + t.Parallel() + req, errRequest := http.NewRequest(http.MethodGet, "https://chatgpt.com/backend-api/subscriptions?account_id=acc", nil) + if errRequest != nil { + t.Fatalf("new request: %v", errRequest) + } + req.Header.Set("Authorization", "Bearer caller-token") + applyChatGPTAPICallHeaderDefaults(req) + + if got := req.Header.Get("Authorization"); got != "Bearer caller-token" { + t.Fatalf("Authorization = %q, want caller token", got) + } + if got := req.Header.Get("User-Agent"); got != chromeAPICallUserAgent { + t.Fatalf("User-Agent = %q, want chrome default", got) + } + if got := req.Header.Get("Accept"); got != "*/*" { + t.Fatalf("Accept = %q, want */*", got) + } + if got := req.Header.Get("Accept-Language"); got != "en-US,en;q=0.9" { + t.Fatalf("Accept-Language = %q, want en-US,en;q=0.9", got) + } + if got := req.Header.Get("OAI-Language"); got != "en-US" { + t.Fatalf("OAI-Language = %q, want en-US", got) + } + if got := req.Header.Get("Referer"); got != "https://chatgpt.com/" { + t.Fatalf("Referer = %q, want https://chatgpt.com/", got) + } + if got := req.Header.Get("X-OpenAI-Target-Path"); got != "/backend-api/subscriptions" { + t.Fatalf("X-OpenAI-Target-Path = %q, want /backend-api/subscriptions", got) + } + if got := req.Header.Get("X-OpenAI-Target-Route"); got != "/backend-api/subscriptions" { + t.Fatalf("X-OpenAI-Target-Route = %q, want /backend-api/subscriptions", got) + } + }) + + t.Run("preserves caller chatgpt headers", func(t *testing.T) { + t.Parallel() + req, errRequest := http.NewRequest(http.MethodGet, "https://chatgpt.com/backend-api/subscriptions", nil) + if errRequest != nil { + t.Fatalf("new request: %v", errRequest) + } + req.Header.Set("User-Agent", "caller-ua") + req.Header.Set("Accept", "application/json") + req.Header.Set("X-OpenAI-Target-Path", "/custom") + applyChatGPTAPICallHeaderDefaults(req) + if got := req.Header.Get("User-Agent"); got != "caller-ua" { + t.Fatalf("User-Agent = %q, want caller-ua", got) + } + if got := req.Header.Get("Accept"); got != "application/json" { + t.Fatalf("Accept = %q, want application/json", got) + } + if got := req.Header.Get("X-OpenAI-Target-Path"); got != "/custom" { + t.Fatalf("X-OpenAI-Target-Path = %q, want /custom", got) + } + }) + + t.Run("skips non chatgpt hosts", func(t *testing.T) { + t.Parallel() + req, errRequest := http.NewRequest(http.MethodGet, "https://api.example.com/v1/ping", nil) + if errRequest != nil { + t.Fatalf("new request: %v", errRequest) + } + applyChatGPTAPICallHeaderDefaults(req) + if got := req.Header.Get("User-Agent"); got != "" { + t.Fatalf("User-Agent = %q, want empty", got) + } + if got := req.Header.Get("X-OpenAI-Target-Path"); got != "" { + t.Fatalf("X-OpenAI-Target-Path = %q, want empty", got) + } + }) +} + +func TestAPICallChatGPTUsesChromeTransportAndPassesHeaders(t *testing.T) { + var gotProxy string + var gotReq *http.Request + stubChromeAPICallTransport(t, &gotProxy, apiCallRoundTripFunc(func(req *http.Request) (*http.Response, error) { + gotReq = req.Clone(req.Context()) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"active_until":"2099-01-01T00:00:00Z"}`)), + Request: req, + }, nil + })) + + h := &Handler{ + cfg: &config.Config{ + SDKConfig: sdkconfig.SDKConfig{ProxyURL: "http://global-proxy.example.com:8080"}, + }, + } + router := gin.New() + router.POST("/", h.APICall) + + body := `{"method":"GET","url":"https://chatgpt.com/backend-api/subscriptions?account_id=acc-1","proxy_url":"http://request-proxy.example.com:8080","header":{"Authorization":"Bearer caller-token","User-Agent":"caller-ua"}}` + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("status code = %d, want %d; body = %s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + var response apiCallResponse + if errDecode := json.NewDecoder(recorder.Body).Decode(&response); errDecode != nil { + t.Fatalf("decode response: %v", errDecode) + } + if response.StatusCode != http.StatusOK { + t.Fatalf("upstream status code = %d, want %d", response.StatusCode, http.StatusOK) + } + if response.Body != `{"active_until":"2099-01-01T00:00:00Z"}` { + t.Fatalf("upstream body = %q, want subscription JSON", response.Body) + } + if gotProxy != "http://request-proxy.example.com:8080" { + t.Fatalf("chrome proxy = %q, want request proxy", gotProxy) + } + if gotReq == nil { + t.Fatal("expected chrome transport to receive the upstream request") + } + if got := gotReq.Header.Get("Authorization"); got != "Bearer caller-token" { + t.Fatalf("Authorization = %q, want Bearer caller-token", got) + } + if got := gotReq.Header.Get("User-Agent"); got != "caller-ua" { + t.Fatalf("User-Agent = %q, want caller-ua", got) + } + if got := gotReq.Header.Get("X-OpenAI-Target-Path"); got != "/backend-api/subscriptions" { + t.Fatalf("X-OpenAI-Target-Path = %q, want default path", got) + } +} + +func TestAPICallNonChatGPTDoesNotUseChromeTransport(t *testing.T) { + chromeCalled := false + stubChromeAPICallTransport(t, nil, apiCallRoundTripFunc(func(req *http.Request) (*http.Response, error) { + chromeCalled = true + return &http.Response{ + StatusCode: http.StatusTeapot, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("chrome")), + Request: req, + }, nil + })) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Caller"); got != "keep-me" { + t.Errorf("X-Caller = %q, want keep-me", got) + } + if got := r.Header.Get("X-OpenAI-Target-Path"); got != "" { + t.Errorf("X-OpenAI-Target-Path = %q, want empty on non-chatgpt host", got) + } + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte("standard")) + })) + defer upstream.Close() + + h := &Handler{} + router := gin.New() + router.POST("/", h.APICall) + + body := `{"method":"GET","url":"` + upstream.URL + `/v1/ping","header":{"X-Caller":"keep-me"}}` + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("status code = %d, want %d; body = %s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + var response apiCallResponse + if errDecode := json.NewDecoder(recorder.Body).Decode(&response); errDecode != nil { + t.Fatalf("decode response: %v", errDecode) + } + if response.StatusCode != http.StatusAccepted { + t.Fatalf("upstream status code = %d, want %d", response.StatusCode, http.StatusAccepted) + } + if response.Body != "standard" { + t.Fatalf("upstream body = %q, want %q", response.Body, "standard") + } + if chromeCalled { + t.Fatal("chrome transport should not handle non-chatgpt hosts") + } +} + +func TestAPICallClientTransportPassesResolvedProxyToChrome(t *testing.T) { + var gotProxy string + original := newChromeAPICallTransport + t.Cleanup(func() { + newChromeAPICallTransport = original + }) + newChromeAPICallTransport = func(proxyURL string) http.RoundTripper { + gotProxy = proxyURL + return apiCallRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Request: req}, nil + }) + } + + h := &Handler{ + cfg: &config.Config{ + SDKConfig: sdkconfig.SDKConfig{ProxyURL: "http://global-proxy.example.com:8080"}, + }, + } + auth := &coreauth.Auth{ProxyURL: "http://credential-proxy.example.com:8080"} + _ = h.apiCallClientTransport(auth, " http://request-proxy.example.com:8080 ") + if gotProxy != "http://request-proxy.example.com:8080" { + t.Fatalf("chrome proxy = %q, want request proxy", gotProxy) + } + + gotProxy = "" + _ = h.apiCallClientTransport(auth, "") + if gotProxy != "http://credential-proxy.example.com:8080" { + t.Fatalf("chrome proxy = %q, want credential proxy", gotProxy) + } + + gotProxy = "" + _ = h.apiCallClientTransport(&coreauth.Auth{ProxyURL: "bad-value"}, "") + if gotProxy != "http://global-proxy.example.com:8080" { + t.Fatalf("chrome proxy = %q, want global proxy", gotProxy) + } + + gotProxy = "" + _ = h.apiCallClientTransport(nil, "") + if gotProxy != "http://global-proxy.example.com:8080" { + t.Fatalf("chrome proxy = %q, want global proxy when auth is nil", gotProxy) + } +} diff --git a/internal/runtime/executor/helps/utls_client.go b/internal/runtime/executor/helps/utls_client.go index 950832013a8..5c45a6887c8 100644 --- a/internal/runtime/executor/helps/utls_client.go +++ b/internal/runtime/executor/helps/utls_client.go @@ -67,6 +67,13 @@ func newUtlsRoundTripper(proxyURL string) *utlsRoundTripper { return &utlsRoundTripper{dialer: dialer} } +// NewChromeRoundTripper returns the Chrome TLS/HTTP2 transport used for +// chatgpt.com. Management APICall reuses this so ChatGPT backend-api calls +// share the same ClientHello as Codex instead of Go's default fingerprint. +func NewChromeRoundTripper(proxyURL string) http.RoundTripper { + return newUtlsRoundTripper(proxyURL) +} + func (t *utlsRoundTripper) createConnection(ctx context.Context, host, addr string) (*http2.ClientConn, error) { contextDialer, ok := t.dialer.(proxy.ContextDialer) if !ok { diff --git a/internal/runtime/executor/helps/utls_client_test.go b/internal/runtime/executor/helps/utls_client_test.go index f4492adc928..5c92fefa53d 100644 --- a/internal/runtime/executor/helps/utls_client_test.go +++ b/internal/runtime/executor/helps/utls_client_test.go @@ -114,6 +114,19 @@ func TestCloseConnectionBodyClosesConnectionBeforeBodyOnce(t *testing.T) { } } +func TestNewChromeRoundTripperUsesDirectDialer(t *testing.T) { + t.Parallel() + + roundTripper := NewChromeRoundTripper("direct") + got, ok := roundTripper.(*utlsRoundTripper) + if !ok { + t.Fatalf("type = %T, want *utlsRoundTripper", roundTripper) + } + if got.dialer == nil { + t.Fatal("expected chrome round tripper to configure a dialer") + } +} + func TestUtlsRoundTripperDialUsesRequestContext(t *testing.T) { dialStarted := make(chan struct{}) roundTripper := &utlsRoundTripper{dialer: contextDialerFunc(func(ctx context.Context, _, _ string) (net.Conn, error) { From 4d9de109beb85161a558eebd9635e1336e0093de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E8=BE=89?= Date: Sun, 13 Sep 2026 07:20:03 +0800 Subject: [PATCH 2/6] refactor(management): reuse shared fingerprint RoundTripper for api-call --- internal/api/handlers/management/api_tools.go | 50 ++++--------- .../api/handlers/management/api_tools_test.go | 73 +++++++------------ .../executor/helps/chatgpt_upstream.go | 19 +++++ .../executor/helps/chatgpt_upstream_test.go | 41 +++++++++++ .../runtime/executor/helps/utls_client.go | 36 ++++++--- 5 files changed, 127 insertions(+), 92 deletions(-) create mode 100644 internal/runtime/executor/helps/chatgpt_upstream.go create mode 100644 internal/runtime/executor/helps/chatgpt_upstream_test.go diff --git a/internal/api/handlers/management/api_tools.go b/internal/api/handlers/management/api_tools.go index b44966467fc..f26361d0500 100644 --- a/internal/api/handlers/management/api_tools.go +++ b/internal/api/handlers/management/api_tools.go @@ -27,10 +27,12 @@ const defaultAPICallTimeout = 60 * time.Second // for chatgpt.com so Go's default "Go-http-client" UA is not sent. const chromeAPICallUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" -// newChromeAPICallTransport builds the Chrome-impersonating transport for -// chatgpt.com. Tests replace this to avoid live TLS handshakes. -var newChromeAPICallTransport = func(proxyURL string) http.RoundTripper { - return helps.NewChromeRoundTripper(proxyURL) +// newAPICallFingerprintTransport builds the shared Anthropic/ChatGPT +// fingerprint RoundTripper. Host routing and ClientHello live in helps so they +// evolve with Codex/Claude instead of a private management copy. Tests replace +// this to avoid live TLS handshakes. +var newAPICallFingerprintTransport = func(proxyURL string, fallback http.RoundTripper) http.RoundTripper { + return helps.NewFingerprintRoundTripper(proxyURL, fallback) } const ( @@ -94,10 +96,11 @@ type apiCallResponse struct { // 3. Global config proxy-url // 4. Direct connect (environment proxies are not used) // -// ChatGPT (https://chatgpt.com) uses the same Chrome TLS/HTTP2 fingerprint as -// Codex. Callers may send ChatGPT web headers (User-Agent, oai-language, -// x-openai-target-path / x-openai-target-route); missing ones are filled only -// for that host. Other hosts keep the standard transport. +// ChatGPT (https://chatgpt.com) and Anthropic HTTPS origins reuse the shared +// helps fingerprint RoundTripper (same routing as Codex/Claude). Callers may +// send ChatGPT web headers (User-Agent, oai-language, x-openai-target-path / +// x-openai-target-route); missing ones are filled only for ChatGPT. Other hosts +// keep the standard transport. // // Response JSON (returned with HTTP 200 when the APICall itself succeeds): // - status_code: Upstream HTTP status code. @@ -612,10 +615,9 @@ func (h *Handler) authByIndex(authIndex string) *coreauth.Auth { } func (h *Handler) apiCallClientTransport(auth *coreauth.Auth, requestProxyURL string) http.RoundTripper { - return &apiCallRoundTripper{ - chrome: newChromeAPICallTransport(h.apiCallProxyURL(auth, requestProxyURL)), - fallback: h.apiCallTransport(auth, requestProxyURL), - } + proxyURL := h.apiCallProxyURL(auth, requestProxyURL) + fallback := h.apiCallTransport(auth, requestProxyURL) + return newAPICallFingerprintTransport(proxyURL, fallback) } func (h *Handler) apiCallProxyURL(auth *coreauth.Auth, requestProxyURL string) string { @@ -663,30 +665,8 @@ func (h *Handler) apiCallTransport(auth *coreauth.Auth, requestProxyURL string) return directAPICallTransport() } -type apiCallRoundTripper struct { - chrome http.RoundTripper - fallback http.RoundTripper -} - -func (t *apiCallRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - if t == nil { - return nil, fmt.Errorf("api-call transport is nil") - } - if usesChromeAPICallTLS(req.URL) && t.chrome != nil { - return t.chrome.RoundTrip(req) - } - if t.fallback == nil { - return nil, fmt.Errorf("api-call fallback transport is nil") - } - return t.fallback.RoundTrip(req) -} - -func usesChromeAPICallTLS(u *url.URL) bool { - return u != nil && u.Scheme == "https" && strings.EqualFold(u.Hostname(), "chatgpt.com") -} - func applyChatGPTAPICallHeaderDefaults(req *http.Request) { - if req == nil || !usesChromeAPICallTLS(req.URL) { + if req == nil || !helps.IsChatGPTUpstreamURL(req.URL) { return } setHeaderDefault := func(key, value string) { diff --git a/internal/api/handlers/management/api_tools_test.go b/internal/api/handlers/management/api_tools_test.go index c4ca36591a0..fca2b1e4d6b 100644 --- a/internal/api/handlers/management/api_tools_test.go +++ b/internal/api/handlers/management/api_tools_test.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "net/http/httptest" - "net/url" "strings" "sync" "sync/atomic" @@ -16,6 +15,7 @@ import ( "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) @@ -26,17 +26,25 @@ func (f apiCallRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, erro return f(req) } -func stubChromeAPICallTransport(t *testing.T, proxy *string, trip apiCallRoundTripFunc) { +func stubAPICallFingerprintTransport(t *testing.T, proxy *string, trip apiCallRoundTripFunc) { t.Helper() - original := newChromeAPICallTransport + original := newAPICallFingerprintTransport t.Cleanup(func() { - newChromeAPICallTransport = original + newAPICallFingerprintTransport = original }) - newChromeAPICallTransport = func(proxyURL string) http.RoundTripper { + newAPICallFingerprintTransport = func(proxyURL string, fallback http.RoundTripper) http.RoundTripper { if proxy != nil { *proxy = proxyURL } - return trip + return apiCallRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if helps.IsChatGPTUpstreamURL(req.URL) { + return trip(req) + } + if fallback == nil { + return nil, io.EOF + } + return fallback.RoundTrip(req) + }) } } @@ -673,36 +681,6 @@ func TestResolveMetaTokenUsesRequestProxyWithoutSavingOverride(t *testing.T) { t.Fatal("request proxy override changed the credential's configured proxy") } } - -func TestUsesChromeAPICallTLS(t *testing.T) { - t.Parallel() - - cases := []struct { - name string - raw string - want bool - }{ - {name: "chatgpt backend-api", raw: "https://chatgpt.com/backend-api/subscriptions", want: true}, - {name: "chatgpt mixed case host", raw: "https://ChatGPT.com/backend-api/codex/responses", want: true}, - {name: "chatgpt http", raw: "http://chatgpt.com/backend-api/subscriptions", want: false}, - {name: "lookalike host", raw: "https://chatgpt.com.example/backend-api/subscriptions", want: false}, - {name: "other https", raw: "https://api.example.com/v1/ping", want: false}, - } - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - parsed, errParse := url.Parse(tc.raw) - if errParse != nil { - t.Fatalf("parse url: %v", errParse) - } - if got := usesChromeAPICallTLS(parsed); got != tc.want { - t.Fatalf("usesChromeAPICallTLS(%q) = %v, want %v", tc.raw, got, tc.want) - } - }) - } -} - func TestApplyChatGPTAPICallHeaderDefaults(t *testing.T) { t.Parallel() @@ -781,7 +759,7 @@ func TestApplyChatGPTAPICallHeaderDefaults(t *testing.T) { func TestAPICallChatGPTUsesChromeTransportAndPassesHeaders(t *testing.T) { var gotProxy string var gotReq *http.Request - stubChromeAPICallTransport(t, &gotProxy, apiCallRoundTripFunc(func(req *http.Request) (*http.Response, error) { + stubAPICallFingerprintTransport(t, &gotProxy, apiCallRoundTripFunc(func(req *http.Request) (*http.Response, error) { gotReq = req.Clone(req.Context()) return &http.Response{ StatusCode: http.StatusOK, @@ -819,7 +797,7 @@ func TestAPICallChatGPTUsesChromeTransportAndPassesHeaders(t *testing.T) { t.Fatalf("upstream body = %q, want subscription JSON", response.Body) } if gotProxy != "http://request-proxy.example.com:8080" { - t.Fatalf("chrome proxy = %q, want request proxy", gotProxy) + t.Fatalf("fingerprint proxy = %q, want request proxy", gotProxy) } if gotReq == nil { t.Fatal("expected chrome transport to receive the upstream request") @@ -837,7 +815,7 @@ func TestAPICallChatGPTUsesChromeTransportAndPassesHeaders(t *testing.T) { func TestAPICallNonChatGPTDoesNotUseChromeTransport(t *testing.T) { chromeCalled := false - stubChromeAPICallTransport(t, nil, apiCallRoundTripFunc(func(req *http.Request) (*http.Response, error) { + stubAPICallFingerprintTransport(t, nil, apiCallRoundTripFunc(func(req *http.Request) (*http.Response, error) { chromeCalled = true return &http.Response{ StatusCode: http.StatusTeapot, @@ -887,14 +865,15 @@ func TestAPICallNonChatGPTDoesNotUseChromeTransport(t *testing.T) { } } -func TestAPICallClientTransportPassesResolvedProxyToChrome(t *testing.T) { +func TestAPICallClientTransportPassesResolvedProxyToFingerprint(t *testing.T) { var gotProxy string - original := newChromeAPICallTransport + original := newAPICallFingerprintTransport t.Cleanup(func() { - newChromeAPICallTransport = original + newAPICallFingerprintTransport = original }) - newChromeAPICallTransport = func(proxyURL string) http.RoundTripper { + newAPICallFingerprintTransport = func(proxyURL string, fallback http.RoundTripper) http.RoundTripper { gotProxy = proxyURL + _ = fallback return apiCallRoundTripFunc(func(req *http.Request) (*http.Response, error) { return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Request: req}, nil }) @@ -908,24 +887,24 @@ func TestAPICallClientTransportPassesResolvedProxyToChrome(t *testing.T) { auth := &coreauth.Auth{ProxyURL: "http://credential-proxy.example.com:8080"} _ = h.apiCallClientTransport(auth, " http://request-proxy.example.com:8080 ") if gotProxy != "http://request-proxy.example.com:8080" { - t.Fatalf("chrome proxy = %q, want request proxy", gotProxy) + t.Fatalf("fingerprint proxy = %q, want request proxy", gotProxy) } gotProxy = "" _ = h.apiCallClientTransport(auth, "") if gotProxy != "http://credential-proxy.example.com:8080" { - t.Fatalf("chrome proxy = %q, want credential proxy", gotProxy) + t.Fatalf("fingerprint proxy = %q, want credential proxy", gotProxy) } gotProxy = "" _ = h.apiCallClientTransport(&coreauth.Auth{ProxyURL: "bad-value"}, "") if gotProxy != "http://global-proxy.example.com:8080" { - t.Fatalf("chrome proxy = %q, want global proxy", gotProxy) + t.Fatalf("fingerprint proxy = %q, want global proxy", gotProxy) } gotProxy = "" _ = h.apiCallClientTransport(nil, "") if gotProxy != "http://global-proxy.example.com:8080" { - t.Fatalf("chrome proxy = %q, want global proxy when auth is nil", gotProxy) + t.Fatalf("fingerprint proxy = %q, want global proxy when auth is nil", gotProxy) } } diff --git a/internal/runtime/executor/helps/chatgpt_upstream.go b/internal/runtime/executor/helps/chatgpt_upstream.go new file mode 100644 index 00000000000..14c5a7e0d99 --- /dev/null +++ b/internal/runtime/executor/helps/chatgpt_upstream.go @@ -0,0 +1,19 @@ +package helps + +import ( + "net/url" + "strings" +) + +// IsChatGPTUpstreamURL reports whether a resolved request targets ChatGPT's +// first-party web origin. Chrome TLS/HTTP2 fingerprinting and ChatGPT web +// header defaults must use this gate so management APICall and Codex cannot +// drift onto lookalike hosts, custom ports, or userinfo URLs as the codebase +// evolves. +func IsChatGPTUpstreamURL(u *url.URL) bool { + if u == nil || u.User != nil || !strings.EqualFold(u.Scheme, "https") || !strings.EqualFold(u.Hostname(), "chatgpt.com") { + return false + } + port := u.Port() + return port == "" || port == "443" +} diff --git a/internal/runtime/executor/helps/chatgpt_upstream_test.go b/internal/runtime/executor/helps/chatgpt_upstream_test.go new file mode 100644 index 00000000000..d0aaeb6ff45 --- /dev/null +++ b/internal/runtime/executor/helps/chatgpt_upstream_test.go @@ -0,0 +1,41 @@ +package helps + +import ( + "net/url" + "testing" +) + +func TestIsChatGPTUpstreamURL(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + raw string + want bool + }{ + {name: "backend-api", raw: "https://chatgpt.com/backend-api/subscriptions", want: true}, + {name: "mixed case host", raw: "https://ChatGPT.com/backend-api/codex/responses", want: true}, + {name: "explicit 443", raw: "https://chatgpt.com:443/backend-api/subscriptions", want: true}, + {name: "http", raw: "http://chatgpt.com/backend-api/subscriptions", want: false}, + {name: "custom port", raw: "https://chatgpt.com:8443/backend-api/subscriptions", want: false}, + {name: "lookalike host", raw: "https://chatgpt.com.example/backend-api/subscriptions", want: false}, + {name: "userinfo", raw: "https://user:pass@chatgpt.com/backend-api/subscriptions", want: false}, + {name: "other https", raw: "https://api.example.com/v1/ping", want: false}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + parsed, errParse := url.Parse(tc.raw) + if errParse != nil { + t.Fatalf("parse url: %v", errParse) + } + if got := IsChatGPTUpstreamURL(parsed); got != tc.want { + t.Fatalf("IsChatGPTUpstreamURL(%q) = %v, want %v", tc.raw, got, tc.want) + } + }) + } + if IsChatGPTUpstreamURL(nil) { + t.Fatal("IsChatGPTUpstreamURL(nil) = true") + } +} diff --git a/internal/runtime/executor/helps/utls_client.go b/internal/runtime/executor/helps/utls_client.go index 5c45a6887c8..c13f2226a15 100644 --- a/internal/runtime/executor/helps/utls_client.go +++ b/internal/runtime/executor/helps/utls_client.go @@ -363,12 +363,27 @@ func (f *fallbackRoundTripper) RoundTrip(req *http.Request) (*http.Response, err if IsAnthropicUpstreamURL(req.URL) { return f.anthropic.RoundTrip(req) } - if req.URL.Scheme == "https" && strings.EqualFold(req.URL.Hostname(), "chatgpt.com") { + if IsChatGPTUpstreamURL(req.URL) { return f.chrome.RoundTrip(req) } return f.fallback.RoundTrip(req) } +// NewFingerprintRoundTripper routes Anthropic and ChatGPT HTTPS origins through +// the same provider TLS fingerprints Codex/Claude already use, and sends every +// other request through fallback. Management APICall must use this helper (not a +// private copy) so host routing and ClientHello selection keep evolving in one place. +func NewFingerprintRoundTripper(proxyURL string, fallback http.RoundTripper) http.RoundTripper { + if fallback == nil { + fallback = http.DefaultTransport + } + return &fallbackRoundTripper{ + anthropic: cachedClaudeCodeRoundTripper(proxyURL), + chrome: newUtlsRoundTripper(proxyURL), + fallback: fallback, + } +} + // NewUtlsHTTPClient creates an HTTP client using provider-specific TLS // fingerprints for protected hosts. It uses Claude Code's Node/OpenSSL profile // for Anthropic and a Chrome profile for ChatGPT, with a standard-transport @@ -387,25 +402,26 @@ func NewUtlsHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyau ctxRoundTripper, _ = ctx.Value("cliproxy.roundtripper").(http.RoundTripper) } - var chromeRT http.RoundTripper = newUtlsRoundTripper(proxyURL) - var anthropicRT http.RoundTripper = cachedClaudeCodeRoundTripper(proxyURL) var standardTransport http.RoundTripper = http.DefaultTransport if proxyURL != "" { if transport := buildProxyTransport(proxyURL); transport != nil { standardTransport = transport } } else if ctxRoundTripper != nil { - chromeRT = ctxRoundTripper - anthropicRT = ctxRoundTripper standardTransport = ctxRoundTripper } + var transport http.RoundTripper + if ctxRoundTripper != nil && proxyURL == "" { + // Preserve the historical override: when a context round tripper is + // injected and no auth/config proxy is set, all hosts share it. + transport = ctxRoundTripper + } else { + transport = NewFingerprintRoundTripper(proxyURL, standardTransport) + } + client := &http.Client{ - Transport: &fallbackRoundTripper{ - anthropic: anthropicRT, - chrome: chromeRT, - fallback: standardTransport, - }, + Transport: transport, } if timeout > 0 { client.Timeout = timeout From 42aa81eff299efcce8952469d62c836315276e8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E8=BE=89?= Date: Sat, 12 Sep 2026 23:32:35 +0000 Subject: [PATCH 3/6] fix(proxyutil): support socks5h in BuildDialer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build SOCKS5/SOCKS5H dialers via proxy.SOCKS5 instead of FromURL so fingerprinted utls clients keep remote-DNS proxy routing. Co-authored-by: 程辉 --- sdk/proxyutil/proxy.go | 32 +++++++++++++++++++++----------- sdk/proxyutil/proxy_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/sdk/proxyutil/proxy.go b/sdk/proxyutil/proxy.go index b1a09ca8964..6d39f787800 100644 --- a/sdk/proxyutil/proxy.go +++ b/sdk/proxyutil/proxy.go @@ -101,15 +101,9 @@ func BuildHTTPTransport(raw string) (*http.Transport, Mode, error) { return NewDirectTransport(), setting.Mode, nil case ModeProxy: if setting.URL.Scheme == "socks5" || setting.URL.Scheme == "socks5h" { - var proxyAuth *proxy.Auth - if setting.URL.User != nil { - username := setting.URL.User.Username() - password, _ := setting.URL.User.Password() - proxyAuth = &proxy.Auth{User: username, Password: password} - } - dialer, errSOCKS5 := proxy.SOCKS5("tcp", setting.URL.Host, proxyAuth, proxy.Direct) + dialer, errSOCKS5 := socks5DialerFromURL(setting.URL) if errSOCKS5 != nil { - return nil, setting.Mode, fmt.Errorf("create SOCKS5 dialer failed: %w", errSOCKS5) + return nil, setting.Mode, errSOCKS5 } transport := cloneDefaultTransport() transport.Proxy = nil @@ -132,6 +126,20 @@ func BuildHTTPTransport(raw string) (*http.Transport, Mode, error) { } } +func socks5DialerFromURL(proxyURL *url.URL) (proxy.Dialer, error) { + var proxyAuth *proxy.Auth + if proxyURL.User != nil { + username := proxyURL.User.Username() + password, _ := proxyURL.User.Password() + proxyAuth = &proxy.Auth{User: username, Password: password} + } + dialer, errSOCKS5 := proxy.SOCKS5("tcp", proxyURL.Host, proxyAuth, proxy.Direct) + if errSOCKS5 != nil { + return nil, fmt.Errorf("create SOCKS5 dialer failed: %w", errSOCKS5) + } + return dialer, nil +} + func buildHTTPSProxyDialTLSContext( proxyURL *url.URL, baseTLS *tls.Config, @@ -199,9 +207,11 @@ func BuildDialer(raw string) (proxy.Dialer, Mode, error) { if setting.URL.Scheme == "http" || setting.URL.Scheme == "https" { return &httpConnectDialer{proxyURL: setting.URL, dialer: proxy.Direct}, setting.Mode, nil } - dialer, errDialer := proxy.FromURL(setting.URL, proxy.Direct) - if errDialer != nil { - return nil, setting.Mode, fmt.Errorf("create proxy dialer failed: %w", errDialer) + // socks5 and socks5h: use SOCKS5 helper (remote DNS). golang.org/x/net/proxy + // FromURL rejects scheme socks5h, which would make utls fall back to Direct. + dialer, errSOCKS5 := socks5DialerFromURL(setting.URL) + if errSOCKS5 != nil { + return nil, setting.Mode, errSOCKS5 } return dialer, setting.Mode, nil default: diff --git a/sdk/proxyutil/proxy_test.go b/sdk/proxyutil/proxy_test.go index 7dc05ce00dc..00e381eb55a 100644 --- a/sdk/proxyutil/proxy_test.go +++ b/sdk/proxyutil/proxy_test.go @@ -222,6 +222,37 @@ func TestBuildHTTPTransportHTTPSProxyInheritsDefaultTransportSettings(t *testing } } +func TestBuildDialerSOCKS5AndSOCKS5H(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + }{ + {name: "socks5", input: "socks5://proxy.example.com:1080"}, + {name: "socks5h", input: "socks5h://proxy.example.com:1080"}, + {name: "socks5h with auth", input: "socks5h://user:pass@proxy.example.com:1080"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dialer, mode, errBuild := BuildDialer(tt.input) + if errBuild != nil { + t.Fatalf("BuildDialer returned error: %v", errBuild) + } + if mode != ModeProxy { + t.Fatalf("mode = %d, want %d", mode, ModeProxy) + } + if dialer == nil { + t.Fatal("expected dialer, got nil") + } + }) + } +} + func TestBuildDialerHTTPProxyCONNECT(t *testing.T) { t.Parallel() From a2a69d2488d1c72d7f560cc2dca3817410007e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E8=BE=89?= Date: Sat, 12 Sep 2026 23:36:17 +0000 Subject: [PATCH 4/6] test(helps): live SOCKS5H proof on fingerprint chrome path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive NewFingerprintRoundTripper through a local SOCKS5 listener and assert chatgpt.com is CONNECT'd as a domain name (remote DNS). Co-authored-by: 程辉 --- .../helps/fingerprint_socks5h_live_test.go | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 internal/runtime/executor/helps/fingerprint_socks5h_live_test.go diff --git a/internal/runtime/executor/helps/fingerprint_socks5h_live_test.go b/internal/runtime/executor/helps/fingerprint_socks5h_live_test.go new file mode 100644 index 00000000000..4b8701920c7 --- /dev/null +++ b/internal/runtime/executor/helps/fingerprint_socks5h_live_test.go @@ -0,0 +1,234 @@ +package helps + +import ( + "bufio" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "testing" + "time" + + "golang.org/x/net/proxy" +) + +type socksConnectLog struct { + ATYP string + Host string + Port uint16 +} + +func (c socksConnectLog) String() string { + return fmt.Sprintf("CONNECT %s %s:%d", c.ATYP, c.Host, c.Port) +} + +func TestFingerprintRoundTripperSOCKS5HUsesProxy(t *testing.T) { + connects := make(chan socksConnectLog, 8) + proxyAddr := startLoggingSOCKS5(t, connects) + + fallback := utlsClientRoundTripFunc(func(req *http.Request) (*http.Response, error) { + t.Errorf("fallback used for %s; expected chrome fingerprint path", req.URL) + return nil, errors.New("fallback should not handle chatgpt.com") + }) + + proxyURL := "socks5h://" + proxyAddr + client := &http.Client{ + Transport: NewFingerprintRoundTripper(proxyURL, fallback), + Timeout: 12 * time.Second, + } + + resp, errGet := client.Get("https://chatgpt.com/") + if resp != nil && resp.Body != nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + t.Logf("GET https://chatgpt.com/ via socks5h status=%d", resp.StatusCode) + } else if errGet != nil { + t.Logf("GET https://chatgpt.com/ via socks5h error (ok if CONNECT was logged): %v", errGet) + } + + select { + case got := <-connects: + t.Logf("socks5h proxy log: %s", got) + if got.Host != "chatgpt.com" { + t.Fatalf("proxy CONNECT host = %q, want chatgpt.com", got.Host) + } + if got.Port != 443 { + t.Fatalf("proxy CONNECT port = %d, want 443", got.Port) + } + if got.ATYP != "domain" { + t.Fatalf("proxy CONNECT atyp = %q, want domain (socks5h remote DNS)", got.ATYP) + } + case <-time.After(8 * time.Second): + t.Fatal("socks5h proxy received no CONNECT; fingerprint path bypassed the proxy") + } +} + +func TestFingerprintRoundTripperDirectMissesSOCKS(t *testing.T) { + connects := make(chan socksConnectLog, 8) + _ = startLoggingSOCKS5(t, connects) + + client := &http.Client{ + Transport: NewFingerprintRoundTripper("direct", http.DefaultTransport), + Timeout: 8 * time.Second, + } + resp, errGet := client.Get("https://chatgpt.com/") + if resp != nil && resp.Body != nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + t.Logf("GET https://chatgpt.com/ via direct status=%d", resp.StatusCode) + } else if errGet != nil { + t.Logf("GET https://chatgpt.com/ via direct error: %v", errGet) + } + + select { + case got := <-connects: + t.Fatalf("direct fingerprint path unexpectedly used SOCKS: %s", got) + case <-time.After(200 * time.Millisecond): + t.Log("direct fingerprint path: proxy received no CONNECT (bypass)") + } +} + +func TestFromURLCurrentlyAcceptsSOCKS5H(t *testing.T) { + t.Parallel() + + parsed, errParse := url.Parse("socks5h://127.0.0.1:1080") + if errParse != nil { + t.Fatalf("url.Parse: %v", errParse) + } + dialer, errFromURL := proxy.FromURL(parsed, proxy.Direct) + if errFromURL != nil { + t.Logf("FromURL(socks5h) error (older x/net behavior): %v", errFromURL) + return + } + t.Logf("FromURL(socks5h) succeeded with %T; BuildDialer still uses SOCKS5 helper to match BuildHTTPTransport", dialer) +} + +func startLoggingSOCKS5(t *testing.T, connects chan<- socksConnectLog) string { + t.Helper() + + listener, errListen := net.Listen("tcp", "127.0.0.1:0") + if errListen != nil { + t.Fatalf("listen SOCKS5: %v", errListen) + } + t.Cleanup(func() { + if errClose := listener.Close(); errClose != nil { + t.Errorf("close SOCKS5 listener: %v", errClose) + } + }) + + go func() { + for { + conn, errAccept := listener.Accept() + if errAccept != nil { + return + } + go handleLoggingSOCKS5(conn, connects) + } + }() + + return listener.Addr().String() +} + +func handleLoggingSOCKS5(conn net.Conn, connects chan<- socksConnectLog) { + defer func() { _ = conn.Close() }() + _ = conn.SetDeadline(time.Now().Add(15 * time.Second)) + + dest, errRead := readSOCKS5Connect(conn) + if errRead != nil { + return + } + select { + case connects <- dest: + default: + } + + if _, errWrite := conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); errWrite != nil { + return + } + + upstream, errDial := net.DialTimeout("tcp", net.JoinHostPort(dest.Host, strconv.Itoa(int(dest.Port))), 8*time.Second) + if errDial != nil { + return + } + defer func() { _ = upstream.Close() }() + + done := make(chan struct{}) + go func() { + _, _ = io.Copy(upstream, conn) + close(done) + }() + _, _ = io.Copy(conn, upstream) + <-done +} + +func readSOCKS5Connect(conn net.Conn) (socksConnectLog, error) { + reader := bufio.NewReader(conn) + + ver, errVer := reader.ReadByte() + if errVer != nil { + return socksConnectLog{}, errVer + } + nmethods, errMethods := reader.ReadByte() + if errMethods != nil { + return socksConnectLog{}, errMethods + } + if ver != 0x05 { + return socksConnectLog{}, fmt.Errorf("socks version %d", ver) + } + if _, errDiscard := io.ReadFull(reader, make([]byte, nmethods)); errDiscard != nil { + return socksConnectLog{}, errDiscard + } + if _, errWrite := conn.Write([]byte{0x05, 0x00}); errWrite != nil { + return socksConnectLog{}, errWrite + } + + header := make([]byte, 4) + if _, errHeader := io.ReadFull(reader, header); errHeader != nil { + return socksConnectLog{}, errHeader + } + if header[0] != 0x05 || header[1] != 0x01 { + return socksConnectLog{}, fmt.Errorf("socks request ver=%d cmd=%d", header[0], header[1]) + } + + dest := socksConnectLog{} + switch header[3] { + case 0x01: + ip := make([]byte, 4) + if _, errIP := io.ReadFull(reader, ip); errIP != nil { + return socksConnectLog{}, errIP + } + dest.ATYP = "ipv4" + dest.Host = net.IP(ip).String() + case 0x03: + length, errLen := reader.ReadByte() + if errLen != nil { + return socksConnectLog{}, errLen + } + name := make([]byte, length) + if _, errName := io.ReadFull(reader, name); errName != nil { + return socksConnectLog{}, errName + } + dest.ATYP = "domain" + dest.Host = string(name) + case 0x04: + ip := make([]byte, 16) + if _, errIP := io.ReadFull(reader, ip); errIP != nil { + return socksConnectLog{}, errIP + } + dest.ATYP = "ipv6" + dest.Host = net.IP(ip).String() + default: + return socksConnectLog{}, fmt.Errorf("socks atyp %d", header[3]) + } + + portBytes := make([]byte, 2) + if _, errPort := io.ReadFull(reader, portBytes); errPort != nil { + return socksConnectLog{}, errPort + } + dest.Port = binary.BigEndian.Uint16(portBytes) + return dest, nil +} From 2c4dd4f851d62e049e359e99c280c3ea5c8a36ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E8=BE=89?= Date: Sun, 13 Sep 2026 01:08:11 +0000 Subject: [PATCH 5/6] test(helps): keep socks5h CONNECT proof offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace public chatgpt.com probes with a local SOCKS5 listener so default go test proves remote-DNS CONNECT without leaving the machine. Direct control dials a closed local port; BuildDialer asserts socks5h is not Direct. Co-authored-by: 程辉 --- ...ve_test.go => fingerprint_socks5h_test.go} | 126 ++++++++++++++---- .../executor/helps/utls_client_test.go | 14 +- sdk/proxyutil/proxy_test.go | 108 +++++++++++++++ 3 files changed, 217 insertions(+), 31 deletions(-) rename internal/runtime/executor/helps/{fingerprint_socks5h_live_test.go => fingerprint_socks5h_test.go} (59%) diff --git a/internal/runtime/executor/helps/fingerprint_socks5h_live_test.go b/internal/runtime/executor/helps/fingerprint_socks5h_test.go similarity index 59% rename from internal/runtime/executor/helps/fingerprint_socks5h_live_test.go rename to internal/runtime/executor/helps/fingerprint_socks5h_test.go index 4b8701920c7..8d75a2bd4bf 100644 --- a/internal/runtime/executor/helps/fingerprint_socks5h_live_test.go +++ b/internal/runtime/executor/helps/fingerprint_socks5h_test.go @@ -9,7 +9,6 @@ import ( "net" "net/http" "net/url" - "strconv" "testing" "time" @@ -26,6 +25,39 @@ func (c socksConnectLog) String() string { return fmt.Sprintf("CONNECT %s %s:%d", c.ATYP, c.Host, c.Port) } +func TestFingerprintChromeDialerSOCKS5HIsNotDirect(t *testing.T) { + t.Parallel() + + constructed := NewFingerprintRoundTripper("socks5h://127.0.0.1:1", http.DefaultTransport) + roundTripper, ok := constructed.(*fallbackRoundTripper) + if !ok { + t.Fatalf("type = %T, want *fallbackRoundTripper", constructed) + } + chrome, ok := roundTripper.chrome.(*utlsRoundTripper) + if !ok { + t.Fatalf("chrome type = %T, want *utlsRoundTripper", roundTripper.chrome) + } + if chrome.dialer == nil { + t.Fatal("socks5h chrome path configured a nil dialer") + } + if chrome.dialer == proxy.Direct { + t.Fatal("socks5h chrome path silently fell back to proxy.Direct") + } + + directConstructed := NewFingerprintRoundTripper("direct", http.DefaultTransport) + directTripper, ok := directConstructed.(*fallbackRoundTripper) + if !ok { + t.Fatalf("direct type = %T, want *fallbackRoundTripper", directConstructed) + } + directChrome, ok := directTripper.chrome.(*utlsRoundTripper) + if !ok { + t.Fatalf("direct chrome type = %T, want *utlsRoundTripper", directTripper.chrome) + } + if directChrome.dialer != proxy.Direct { + t.Fatalf("direct chrome dialer = %T, want proxy.Direct", directChrome.dialer) + } +} + func TestFingerprintRoundTripperSOCKS5HUsesProxy(t *testing.T) { connects := make(chan socksConnectLog, 8) proxyAddr := startLoggingSOCKS5(t, connects) @@ -38,21 +70,22 @@ func TestFingerprintRoundTripperSOCKS5HUsesProxy(t *testing.T) { proxyURL := "socks5h://" + proxyAddr client := &http.Client{ Transport: NewFingerprintRoundTripper(proxyURL, fallback), - Timeout: 12 * time.Second, + Timeout: 2 * time.Second, } + // chatgpt.com is required to select the chrome fingerprint path + // (IsChatGPTUpstreamURL). The local SOCKS listener records CONNECT and + // does not dial the requested host, so this stays offline. resp, errGet := client.Get("https://chatgpt.com/") if resp != nil && resp.Body != nil { _, _ = io.Copy(io.Discard, resp.Body) _ = resp.Body.Close() - t.Logf("GET https://chatgpt.com/ via socks5h status=%d", resp.StatusCode) - } else if errGet != nil { - t.Logf("GET https://chatgpt.com/ via socks5h error (ok if CONNECT was logged): %v", errGet) + } else if errGet == nil { + t.Fatal("expected GET through the local SOCKS sink to fail") } select { case got := <-connects: - t.Logf("socks5h proxy log: %s", got) if got.Host != "chatgpt.com" { t.Fatalf("proxy CONNECT host = %q, want chatgpt.com", got.Host) } @@ -62,7 +95,7 @@ func TestFingerprintRoundTripperSOCKS5HUsesProxy(t *testing.T) { if got.ATYP != "domain" { t.Fatalf("proxy CONNECT atyp = %q, want domain (socks5h remote DNS)", got.ATYP) } - case <-time.After(8 * time.Second): + case <-time.After(time.Second): t.Fatal("socks5h proxy received no CONNECT; fingerprint path bypassed the proxy") } } @@ -71,24 +104,57 @@ func TestFingerprintRoundTripperDirectMissesSOCKS(t *testing.T) { connects := make(chan socksConnectLog, 8) _ = startLoggingSOCKS5(t, connects) + // Dial a local closed port through the chrome path so Direct never leaves + // the machine. A chatgpt.com GET would hit the public internet via proxy.Direct. + local := startClosedLocalAddr(t) client := &http.Client{ - Transport: NewFingerprintRoundTripper("direct", http.DefaultTransport), - Timeout: 8 * time.Second, + Transport: NewChromeRoundTripper("direct"), + Timeout: 2 * time.Second, } - resp, errGet := client.Get("https://chatgpt.com/") + resp, errGet := client.Get("https://" + local + "/") if resp != nil && resp.Body != nil { _, _ = io.Copy(io.Discard, resp.Body) _ = resp.Body.Close() - t.Logf("GET https://chatgpt.com/ via direct status=%d", resp.StatusCode) - } else if errGet != nil { - t.Logf("GET https://chatgpt.com/ via direct error: %v", errGet) + } else if errGet == nil { + t.Fatal("expected direct GET to the closed local address to fail") } select { case got := <-connects: t.Fatalf("direct fingerprint path unexpectedly used SOCKS: %s", got) case <-time.After(200 * time.Millisecond): - t.Log("direct fingerprint path: proxy received no CONNECT (bypass)") + } +} + +func TestChromeRoundTripperSOCKS5HDialsLocalHost(t *testing.T) { + connects := make(chan socksConnectLog, 8) + proxyAddr := startLoggingSOCKS5(t, connects) + + client := &http.Client{ + Transport: NewChromeRoundTripper("socks5h://" + proxyAddr), + Timeout: 2 * time.Second, + } + resp, errGet := client.Get("https://chatgpt.local.test/") + if resp != nil && resp.Body != nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + } else if errGet == nil { + t.Fatal("expected GET through the local SOCKS sink to fail") + } + + select { + case got := <-connects: + if got.Host != "chatgpt.local.test" { + t.Fatalf("proxy CONNECT host = %q, want chatgpt.local.test", got.Host) + } + if got.Port != 443 { + t.Fatalf("proxy CONNECT port = %d, want 443", got.Port) + } + if got.ATYP != "domain" { + t.Fatalf("proxy CONNECT atyp = %q, want domain (socks5h remote DNS)", got.ATYP) + } + case <-time.After(time.Second): + t.Fatal("socks5h chrome path received no CONNECT") } } @@ -107,6 +173,20 @@ func TestFromURLCurrentlyAcceptsSOCKS5H(t *testing.T) { t.Logf("FromURL(socks5h) succeeded with %T; BuildDialer still uses SOCKS5 helper to match BuildHTTPTransport", dialer) } +func startClosedLocalAddr(t *testing.T) string { + t.Helper() + + listener, errListen := net.Listen("tcp", "127.0.0.1:0") + if errListen != nil { + t.Fatalf("listen local sink: %v", errListen) + } + addr := listener.Addr().String() + if errClose := listener.Close(); errClose != nil { + t.Fatalf("close local sink: %v", errClose) + } + return addr +} + func startLoggingSOCKS5(t *testing.T, connects chan<- socksConnectLog) string { t.Helper() @@ -135,7 +215,7 @@ func startLoggingSOCKS5(t *testing.T, connects chan<- socksConnectLog) string { func handleLoggingSOCKS5(conn net.Conn, connects chan<- socksConnectLog) { defer func() { _ = conn.Close() }() - _ = conn.SetDeadline(time.Now().Add(15 * time.Second)) + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) dest, errRead := readSOCKS5Connect(conn) if errRead != nil { @@ -149,20 +229,8 @@ func handleLoggingSOCKS5(conn net.Conn, connects chan<- socksConnectLog) { if _, errWrite := conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); errWrite != nil { return } - - upstream, errDial := net.DialTimeout("tcp", net.JoinHostPort(dest.Host, strconv.Itoa(int(dest.Port))), 8*time.Second) - if errDial != nil { - return - } - defer func() { _ = upstream.Close() }() - - done := make(chan struct{}) - go func() { - _, _ = io.Copy(upstream, conn) - close(done) - }() - _, _ = io.Copy(conn, upstream) - <-done + // Do not dial dest.Host: unit tests must stay offline even when the + // chrome path CONNECTs chatgpt.com for IsChatGPTUpstreamURL routing. } func readSOCKS5Connect(conn net.Conn) (socksConnectLog, error) { diff --git a/internal/runtime/executor/helps/utls_client_test.go b/internal/runtime/executor/helps/utls_client_test.go index 5c92fefa53d..0a894ac330b 100644 --- a/internal/runtime/executor/helps/utls_client_test.go +++ b/internal/runtime/executor/helps/utls_client_test.go @@ -21,6 +21,7 @@ import ( tls "github.com/refraction-networking/utls" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "golang.org/x/net/proxy" ) type utlsClientRoundTripFunc func(*http.Request) (*http.Response, error) @@ -122,8 +123,17 @@ func TestNewChromeRoundTripperUsesDirectDialer(t *testing.T) { if !ok { t.Fatalf("type = %T, want *utlsRoundTripper", roundTripper) } - if got.dialer == nil { - t.Fatal("expected chrome round tripper to configure a dialer") + if got.dialer != proxy.Direct { + t.Fatalf("direct chrome dialer = %T, want proxy.Direct", got.dialer) + } + + socks := NewChromeRoundTripper("socks5h://127.0.0.1:1") + gotSOCKS, ok := socks.(*utlsRoundTripper) + if !ok { + t.Fatalf("socks5h type = %T, want *utlsRoundTripper", socks) + } + if gotSOCKS.dialer == nil || gotSOCKS.dialer == proxy.Direct { + t.Fatal("socks5h chrome path silently fell back to proxy.Direct") } } diff --git a/sdk/proxyutil/proxy_test.go b/sdk/proxyutil/proxy_test.go index 00e381eb55a..c97da1c629c 100644 --- a/sdk/proxyutil/proxy_test.go +++ b/sdk/proxyutil/proxy_test.go @@ -10,6 +10,7 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/base64" + "encoding/binary" "errors" "fmt" "io" @@ -21,6 +22,8 @@ import ( "strings" "testing" "time" + + "golang.org/x/net/proxy" ) func mustDefaultTransport(t *testing.T) *http.Transport { @@ -249,10 +252,115 @@ func TestBuildDialerSOCKS5AndSOCKS5H(t *testing.T) { if dialer == nil { t.Fatal("expected dialer, got nil") } + if dialer == proxy.Direct { + t.Fatal("BuildDialer silently returned proxy.Direct for a SOCKS proxy URL") + } }) } } +func TestBuildDialerSOCKS5HCONNECTRemoteDNS(t *testing.T) { + t.Parallel() + + type connectLog struct { + atyp byte + host string + port uint16 + } + connects := make(chan connectLog, 1) + listener, errListen := net.Listen("tcp", "127.0.0.1:0") + if errListen != nil { + t.Fatalf("listen SOCKS5: %v", errListen) + } + defer func() { + if errClose := listener.Close(); errClose != nil { + t.Errorf("close SOCKS5 listener: %v", errClose) + } + }() + + go func() { + conn, errAccept := listener.Accept() + if errAccept != nil { + return + } + defer func() { _ = conn.Close() }() + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + + reader := bufio.NewReader(conn) + header := make([]byte, 2) + if _, errHeader := io.ReadFull(reader, header); errHeader != nil { + return + } + if _, errMethods := io.ReadFull(reader, make([]byte, header[1])); errMethods != nil { + return + } + if _, errWrite := conn.Write([]byte{0x05, 0x00}); errWrite != nil { + return + } + + req := make([]byte, 4) + if _, errReq := io.ReadFull(reader, req); errReq != nil { + return + } + got := connectLog{atyp: req[3]} + switch req[3] { + case 0x03: + length, errLen := reader.ReadByte() + if errLen != nil { + return + } + name := make([]byte, length) + if _, errName := io.ReadFull(reader, name); errName != nil { + return + } + got.host = string(name) + default: + return + } + portBytes := make([]byte, 2) + if _, errPort := io.ReadFull(reader, portBytes); errPort != nil { + return + } + got.port = binary.BigEndian.Uint16(portBytes) + if _, errWrite := conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); errWrite != nil { + return + } + connects <- got + }() + + dialer, mode, errBuild := BuildDialer("socks5h://" + listener.Addr().String()) + if errBuild != nil { + t.Fatalf("BuildDialer returned error: %v", errBuild) + } + if mode != ModeProxy { + t.Fatalf("mode = %d, want %d", mode, ModeProxy) + } + if dialer == nil || dialer == proxy.Direct { + t.Fatal("BuildDialer(socks5h) silently returned proxy.Direct") + } + + conn, errDial := dialer.Dial("tcp", "chatgpt.com:443") + if errDial != nil { + t.Fatalf("dialer.Dial returned error: %v", errDial) + } + defer func() { _ = conn.Close() }() + + select { + case got := <-connects: + if got.atyp != 0x03 { + t.Fatalf("SOCKS ATYP = %d, want 3 (domain)", got.atyp) + } + if got.host != "chatgpt.com" { + t.Fatalf("SOCKS CONNECT host = %q, want chatgpt.com", got.host) + } + if got.port != 443 { + t.Fatalf("SOCKS CONNECT port = %d, want 443", got.port) + } + case <-time.After(time.Second): + t.Fatal("SOCKS5H dialer did not send a CONNECT") + } +} + func TestBuildDialerHTTPProxyCONNECT(t *testing.T) { t.Parallel() From 209e22923e1a58a30b179df84c74807eced3ede6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E8=BE=89?= Date: Mon, 14 Sep 2026 10:50:40 +0800 Subject: [PATCH 6/6] chore: re-attribute branch tip push to HuiCheng