diff --git a/backend/internal/repository/proxy_probe_service.go b/backend/internal/repository/proxy_probe_service.go index fb6f405e..513e929c 100644 --- a/backend/internal/repository/proxy_probe_service.go +++ b/backend/internal/repository/proxy_probe_service.go @@ -28,7 +28,6 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber { log.Printf("[ProxyProbe] Warning: insecure_skip_verify is not allowed and will cause probe failure.") } return &proxyProbeService{ - ipInfoURL: defaultIPInfoURL, insecureSkipVerify: insecure, allowPrivateHosts: allowPrivate, validateResolvedIP: validateResolvedIP, @@ -36,12 +35,20 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber { } const ( - defaultIPInfoURL = "http://ip-api.com/json/?lang=zh-CN" defaultProxyProbeTimeout = 30 * time.Second ) +// probeURLs 按优先级排列的探测 URL 列表 +// 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选 +var probeURLs = []struct { + url string + parser string // "ip-api" or "httpbin" +}{ + {"http://ip-api.com/json/?lang=zh-CN", "ip-api"}, + {"http://httpbin.org/ip", "httpbin"}, +} + type proxyProbeService struct { - ipInfoURL string insecureSkipVerify bool allowPrivateHosts bool validateResolvedIP bool @@ -60,8 +67,21 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s return nil, 0, fmt.Errorf("failed to create proxy client: %w", err) } + var lastErr error + for _, probe := range probeURLs { + exitInfo, latencyMs, err := s.probeWithURL(ctx, client, probe.url, probe.parser) + if err == nil { + return exitInfo, latencyMs, nil + } + lastErr = err + } + + return nil, 0, fmt.Errorf("all probe URLs failed, last error: %w", lastErr) +} + +func (s *proxyProbeService) probeWithURL(ctx context.Context, client *http.Client, url string, parser string) (*service.ProxyExitInfo, int64, error) { startTime := time.Now() - req, err := http.NewRequestWithContext(ctx, "GET", s.ipInfoURL, nil) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, 0, fmt.Errorf("failed to create request: %w", err) } @@ -78,6 +98,22 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s return nil, latencyMs, fmt.Errorf("request failed with status: %d", resp.StatusCode) } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, latencyMs, fmt.Errorf("failed to read response: %w", err) + } + + switch parser { + case "ip-api": + return s.parseIPAPI(body, latencyMs) + case "httpbin": + return s.parseHTTPBin(body, latencyMs) + default: + return nil, latencyMs, fmt.Errorf("unknown parser: %s", parser) + } +} + +func (s *proxyProbeService) parseIPAPI(body []byte, latencyMs int64) (*service.ProxyExitInfo, int64, error) { var ipInfo struct { Status string `json:"status"` Message string `json:"message"` @@ -89,13 +125,12 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s CountryCode string `json:"countryCode"` } - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, latencyMs, fmt.Errorf("failed to read response: %w", err) - } - if err := json.Unmarshal(body, &ipInfo); err != nil { - return nil, latencyMs, fmt.Errorf("failed to parse response: %w", err) + preview := string(body) + if len(preview) > 200 { + preview = preview[:200] + "..." + } + return nil, latencyMs, fmt.Errorf("failed to parse response: %w (body: %s)", err, preview) } if strings.ToLower(ipInfo.Status) != "success" { if ipInfo.Message == "" { @@ -116,3 +151,19 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s CountryCode: ipInfo.CountryCode, }, latencyMs, nil } + +func (s *proxyProbeService) parseHTTPBin(body []byte, latencyMs int64) (*service.ProxyExitInfo, int64, error) { + // httpbin.org/ip 返回格式: {"origin": "1.2.3.4"} + var result struct { + Origin string `json:"origin"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, latencyMs, fmt.Errorf("failed to parse httpbin response: %w", err) + } + if result.Origin == "" { + return nil, latencyMs, fmt.Errorf("httpbin: no IP found in response") + } + return &service.ProxyExitInfo{ + IP: result.Origin, + }, latencyMs, nil +} diff --git a/backend/internal/repository/proxy_probe_service_test.go b/backend/internal/repository/proxy_probe_service_test.go index f1cd5721..7450653b 100644 --- a/backend/internal/repository/proxy_probe_service_test.go +++ b/backend/internal/repository/proxy_probe_service_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/require" @@ -21,7 +22,6 @@ type ProxyProbeServiceSuite struct { func (s *ProxyProbeServiceSuite) SetupTest() { s.ctx = context.Background() s.prober = &proxyProbeService{ - ipInfoURL: "http://ip-api.test/json/?lang=zh-CN", allowPrivateHosts: true, } } @@ -49,12 +49,16 @@ func (s *ProxyProbeServiceSuite) TestProbeProxy_UnsupportedProxyScheme() { require.ErrorContains(s.T(), err, "failed to create proxy client") } -func (s *ProxyProbeServiceSuite) TestProbeProxy_Success() { - seen := make(chan string, 1) +func (s *ProxyProbeServiceSuite) TestProbeProxy_Success_IPAPI() { s.setupProxyServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - seen <- r.RequestURI - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"status":"success","query":"1.2.3.4","city":"c","regionName":"r","country":"cc","countryCode":"CC"}`) + // 检查是否是 ip-api 请求 + if strings.Contains(r.RequestURI, "ip-api.com") { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"status":"success","query":"1.2.3.4","city":"c","regionName":"r","country":"cc","countryCode":"CC"}`) + return + } + // 其他请求返回错误 + w.WriteHeader(http.StatusServiceUnavailable) })) info, latencyMs, err := s.prober.ProbeProxy(s.ctx, s.proxySrv.URL) @@ -65,45 +69,59 @@ func (s *ProxyProbeServiceSuite) TestProbeProxy_Success() { require.Equal(s.T(), "r", info.Region) require.Equal(s.T(), "cc", info.Country) require.Equal(s.T(), "CC", info.CountryCode) - - // Verify proxy received the request - select { - case uri := <-seen: - require.Contains(s.T(), uri, "ip-api.test", "expected request to go through proxy") - default: - require.Fail(s.T(), "expected proxy to receive request") - } } -func (s *ProxyProbeServiceSuite) TestProbeProxy_NonOKStatus() { +func (s *ProxyProbeServiceSuite) TestProbeProxy_Success_HTTPBinFallback() { + s.setupProxyServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // ip-api 失败 + if strings.Contains(r.RequestURI, "ip-api.com") { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + // httpbin 成功 + if strings.Contains(r.RequestURI, "httpbin.org") { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"origin": "5.6.7.8"}`) + return + } + w.WriteHeader(http.StatusServiceUnavailable) + })) + + info, latencyMs, err := s.prober.ProbeProxy(s.ctx, s.proxySrv.URL) + require.NoError(s.T(), err, "ProbeProxy should fallback to httpbin") + require.GreaterOrEqual(s.T(), latencyMs, int64(0), "unexpected latency") + require.Equal(s.T(), "5.6.7.8", info.IP) +} + +func (s *ProxyProbeServiceSuite) TestProbeProxy_AllFailed() { s.setupProxyServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) })) _, _, err := s.prober.ProbeProxy(s.ctx, s.proxySrv.URL) require.Error(s.T(), err) - require.ErrorContains(s.T(), err, "status: 503") + require.ErrorContains(s.T(), err, "all probe URLs failed") } func (s *ProxyProbeServiceSuite) TestProbeProxy_InvalidJSON() { s.setupProxyServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, "not-json") + if strings.Contains(r.RequestURI, "ip-api.com") { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, "not-json") + return + } + // httpbin 也返回无效响应 + if strings.Contains(r.RequestURI, "httpbin.org") { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, "not-json") + return + } + w.WriteHeader(http.StatusServiceUnavailable) })) _, _, err := s.prober.ProbeProxy(s.ctx, s.proxySrv.URL) require.Error(s.T(), err) - require.ErrorContains(s.T(), err, "failed to parse response") -} - -func (s *ProxyProbeServiceSuite) TestProbeProxy_InvalidIPInfoURL() { - s.prober.ipInfoURL = "://invalid-url" - s.setupProxyServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - - _, _, err := s.prober.ProbeProxy(s.ctx, s.proxySrv.URL) - require.Error(s.T(), err, "expected error for invalid ipInfoURL") + require.ErrorContains(s.T(), err, "all probe URLs failed") } func (s *ProxyProbeServiceSuite) TestProbeProxy_ProxyServerClosed() { @@ -114,6 +132,40 @@ func (s *ProxyProbeServiceSuite) TestProbeProxy_ProxyServerClosed() { require.Error(s.T(), err, "expected error when proxy server is closed") } +func (s *ProxyProbeServiceSuite) TestParseIPAPI_Success() { + body := []byte(`{"status":"success","query":"1.2.3.4","city":"Beijing","regionName":"Beijing","country":"China","countryCode":"CN"}`) + info, latencyMs, err := s.prober.parseIPAPI(body, 100) + require.NoError(s.T(), err) + require.Equal(s.T(), int64(100), latencyMs) + require.Equal(s.T(), "1.2.3.4", info.IP) + require.Equal(s.T(), "Beijing", info.City) + require.Equal(s.T(), "Beijing", info.Region) + require.Equal(s.T(), "China", info.Country) + require.Equal(s.T(), "CN", info.CountryCode) +} + +func (s *ProxyProbeServiceSuite) TestParseIPAPI_Failure() { + body := []byte(`{"status":"fail","message":"rate limited"}`) + _, _, err := s.prober.parseIPAPI(body, 100) + require.Error(s.T(), err) + require.ErrorContains(s.T(), err, "rate limited") +} + +func (s *ProxyProbeServiceSuite) TestParseHTTPBin_Success() { + body := []byte(`{"origin": "9.8.7.6"}`) + info, latencyMs, err := s.prober.parseHTTPBin(body, 50) + require.NoError(s.T(), err) + require.Equal(s.T(), int64(50), latencyMs) + require.Equal(s.T(), "9.8.7.6", info.IP) +} + +func (s *ProxyProbeServiceSuite) TestParseHTTPBin_NoIP() { + body := []byte(`{"origin": ""}`) + _, _, err := s.prober.parseHTTPBin(body, 50) + require.Error(s.T(), err) + require.ErrorContains(s.T(), err, "no IP found") +} + func TestProxyProbeServiceSuite(t *testing.T) { suite.Run(t, new(ProxyProbeServiceSuite)) }