diff --git a/internal/api/handlers/management/api_tools.go b/internal/api/handlers/management/api_tools.go index e1251920..a619afd1 100644 --- a/internal/api/handlers/management/api_tools.go +++ b/internal/api/handlers/management/api_tools.go @@ -32,6 +32,7 @@ type apiCallRequest struct { AuthIndexPascal *string `json:"AuthIndex"` Method string `json:"method"` URL string `json:"url"` + ProxyURL string `json:"proxy_url"` Header map[string]string `json:"header"` Data string `json:"data"` } @@ -62,6 +63,8 @@ type apiCallResponse struct { // If omitted or not found, credential-specific proxy/token substitution is skipped. // - method (required): HTTP method, e.g. GET, POST, PUT, PATCH, DELETE. // - url (required): Absolute URL including scheme and host, e.g. "https://api.example.com/v1/ping". +// - proxy_url (optional): Proxy used for this request. Supports HTTP, HTTPS, SOCKS5, SOCKS5H, +// and "direct"/"none" to explicitly bypass proxies. When set, credential and global proxies are ignored. // - header (optional): Request headers map. // Supports magic variable "$TOKEN$" which is replaced using the selected credential: // 1) metadata.access_token @@ -72,9 +75,10 @@ type apiCallResponse struct { // - data (optional): Raw request body as string (useful for POST/PUT/PATCH). // // Proxy selection (highest priority first): -// 1. Selected credential proxy_url -// 2. Global config proxy-url -// 3. Direct connect (environment proxies are not used) +// 1. Request proxy_url (when set, lower-priority proxy settings are ignored) +// 2. Selected credential proxy_url +// 3. Global config proxy-url +// 4. Direct connect (environment proxies are not used) // // Response JSON (returned with HTTP 200 when the APICall itself succeeds): // - status_code: Upstream HTTP status code. @@ -116,6 +120,14 @@ func (h *Handler) APICall(c *gin.Context) { return } + requestProxyURL := strings.TrimSpace(body.ProxyURL) + if requestProxyURL != "" { + if _, errParseProxy := proxyutil.Parse(requestProxyURL); errParseProxy != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid proxy_url"}) + return + } + } + authIndex := firstNonEmptyString(body.AuthIndexSnake, body.AuthIndexCamel, body.AuthIndexPascal) auth := h.authByIndex(authIndex) @@ -133,7 +145,7 @@ func (h *Handler) APICall(c *gin.Context) { continue } if !tokenResolved { - token, tokenErr = h.resolveTokenForAuth(c.Request.Context(), auth) + token, tokenErr = h.resolveTokenForAuth(c.Request.Context(), auth, requestProxyURL) tokenResolved = true } if auth != nil && token == "" { @@ -175,7 +187,7 @@ func (h *Handler) APICall(c *gin.Context) { httpClient := &http.Client{ Timeout: defaultAPICallTimeout, } - httpClient.Transport = h.apiCallTransport(auth) + httpClient.Transport = h.apiCallTransport(auth, requestProxyURL) resp, errDo := httpClient.Do(req) if errDo != nil { @@ -229,20 +241,20 @@ func tokenValueForAuth(auth *coreauth.Auth) string { return "" } -func (h *Handler) resolveTokenForAuth(ctx context.Context, auth *coreauth.Auth) (string, error) { +func (h *Handler) resolveTokenForAuth(ctx context.Context, auth *coreauth.Auth, requestProxyURL string) (string, error) { if auth == nil { return "", nil } if strings.EqualFold(strings.TrimSpace(auth.Provider), "antigravity") { - token, errToken := h.refreshAntigravityOAuthAccessToken(ctx, auth) + token, errToken := h.refreshAntigravityOAuthAccessToken(ctx, auth, requestProxyURL) return token, errToken } return tokenValueForAuth(auth), nil } -func (h *Handler) refreshAntigravityOAuthAccessToken(ctx context.Context, auth *coreauth.Auth) (string, error) { +func (h *Handler) refreshAntigravityOAuthAccessToken(ctx context.Context, auth *coreauth.Auth, requestProxyURL string) (string, error) { if ctx == nil { ctx = context.Background() } @@ -283,7 +295,7 @@ func (h *Handler) refreshAntigravityOAuthAccessToken(ctx context.Context, auth * httpClient := &http.Client{ Timeout: defaultAPICallTimeout, - Transport: h.apiCallTransport(auth), + Transport: h.apiCallTransport(auth, requestProxyURL), } resp, errDo := httpClient.Do(req) if errDo != nil { @@ -469,7 +481,14 @@ func (h *Handler) authByIndex(authIndex string) *coreauth.Auth { return nil } -func (h *Handler) apiCallTransport(auth *coreauth.Auth) http.RoundTripper { +func (h *Handler) apiCallTransport(auth *coreauth.Auth, requestProxyURL string) http.RoundTripper { + if proxyStr := strings.TrimSpace(requestProxyURL); proxyStr != "" { + if transport := buildProxyTransport(proxyStr); transport != nil { + return transport + } + return directAPICallTransport() + } + var proxyCandidates []string if auth != nil { if proxyStr := strings.TrimSpace(auth.ProxyURL); proxyStr != "" { @@ -493,6 +512,10 @@ func (h *Handler) apiCallTransport(auth *coreauth.Auth) http.RoundTripper { } } + return directAPICallTransport() +} + +func directAPICallTransport() http.RoundTripper { transport, ok := http.DefaultTransport.(*http.Transport) if !ok || transport == nil { return &http.Transport{Proxy: nil} diff --git a/internal/api/handlers/management/api_tools_test.go b/internal/api/handlers/management/api_tools_test.go index ca1f3137..a50da2d3 100644 --- a/internal/api/handlers/management/api_tools_test.go +++ b/internal/api/handlers/management/api_tools_test.go @@ -2,14 +2,57 @@ package management import ( "context" + "encoding/json" "net/http" + "net/http/httptest" + "strings" "testing" + "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) +func TestAPICallUsesRequestProxyURL(t *testing.T) { + t.Parallel() + + proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte("proxied")) + })) + defer proxyServer.Close() + + h := &Handler{ + cfg: &config.Config{ + SDKConfig: sdkconfig.SDKConfig{ProxyURL: "http://127.0.0.1:1"}, + }, + } + router := gin.New() + router.POST("/", h.APICall) + + body := `{"method":"GET","url":"http://upstream.invalid/test","proxy_url":"` + proxyServer.URL + `"}` + 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.StatusCreated { + t.Fatalf("upstream status code = %d, want %d", response.StatusCode, http.StatusCreated) + } + if response.Body != "proxied" { + t.Fatalf("upstream body = %q, want %q", response.Body, "proxied") + } +} + func TestAPICallTransportDirectBypassesGlobalProxy(t *testing.T) { t.Parallel() @@ -19,7 +62,7 @@ func TestAPICallTransportDirectBypassesGlobalProxy(t *testing.T) { }, } - transport := h.apiCallTransport(&coreauth.Auth{ProxyURL: "direct"}) + transport := h.apiCallTransport(&coreauth.Auth{ProxyURL: "direct"}, "") httpTransport, ok := transport.(*http.Transport) if !ok { t.Fatalf("transport type = %T, want *http.Transport", transport) @@ -38,7 +81,7 @@ func TestAPICallTransportInvalidAuthFallsBackToGlobalProxy(t *testing.T) { }, } - transport := h.apiCallTransport(&coreauth.Auth{ProxyURL: "bad-value"}) + transport := h.apiCallTransport(&coreauth.Auth{ProxyURL: "bad-value"}, "") httpTransport, ok := transport.(*http.Transport) if !ok { t.Fatalf("transport type = %T, want *http.Transport", transport) @@ -58,6 +101,56 @@ func TestAPICallTransportInvalidAuthFallsBackToGlobalProxy(t *testing.T) { } } +func TestAPICallTransportRequestProxyOverridesCredentialAndGlobalProxy(t *testing.T) { + t.Parallel() + + 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"} + + transport := h.apiCallTransport(auth, " http://request-proxy.example.com:8080 ") + httpTransport, ok := transport.(*http.Transport) + if !ok { + t.Fatalf("transport type = %T, want *http.Transport", transport) + } + + req, errRequest := http.NewRequest(http.MethodGet, "https://example.com", nil) + if errRequest != nil { + t.Fatalf("http.NewRequest returned error: %v", errRequest) + } + + proxyURL, errProxy := httpTransport.Proxy(req) + if errProxy != nil { + t.Fatalf("httpTransport.Proxy returned error: %v", errProxy) + } + if proxyURL == nil || proxyURL.String() != "http://request-proxy.example.com:8080" { + t.Fatalf("proxy URL = %v, want http://request-proxy.example.com:8080", proxyURL) + } +} + +func TestAPICallTransportInvalidRequestProxyDoesNotFallBack(t *testing.T) { + t.Parallel() + + 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"} + + transport := h.apiCallTransport(auth, "bad-value") + httpTransport, ok := transport.(*http.Transport) + if !ok { + t.Fatalf("transport type = %T, want *http.Transport", transport) + } + if httpTransport.Proxy != nil { + t.Fatal("expected invalid request proxy to avoid lower-priority proxy settings") + } +} + func TestAPICallTransportAPIKeyAuthFallsBackToConfigProxyURL(t *testing.T) { t.Parallel() @@ -147,7 +240,7 @@ func TestAPICallTransportAPIKeyAuthFallsBackToConfigProxyURL(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - transport := h.apiCallTransport(tc.auth) + transport := h.apiCallTransport(tc.auth, "") httpTransport, ok := transport.(*http.Transport) if !ok { t.Fatalf("transport type = %T, want *http.Transport", transport)