From b0464a986303d4e4e300d5fcb5b66b0d78a51b2b Mon Sep 17 00:00:00 2001 From: hank Date: Wed, 19 Aug 2026 16:28:53 +0800 Subject: [PATCH 1/4] feat(proxy): allow configurable probe targets --- backend/internal/config/config.go | 10 +++ .../repository/proxy_probe_service.go | 69 +++++++++++++++++-- .../repository/proxy_probe_service_test.go | 16 +++++ deploy/config.example.yaml | 10 +++ 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e437b11150..798c08017c 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -830,6 +830,16 @@ type ProxyFallbackConfig struct { type ProxyProbeConfig struct { InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` // 已禁用:禁止跳过 TLS 证书验证 + // URLs 按优先级排列的自定义探测 URL 列表。 + // 留空时使用内置默认列表(ip-api → ipify)。 + // 某些 AI API 专用代理只允许访问特定域名,配置多个备选可提高探测成功率。 + URLs []ProbeURLConfig `mapstructure:"urls"` +} + +// ProbeURLConfig 描述一个探测端点及其响应解析方式。 +type ProbeURLConfig struct { + URL string `mapstructure:"url"` + Parser string `mapstructure:"parser"` // "ip-api" / "ipify" / "chatgpt-trace" } type BillingConfig struct { diff --git a/backend/internal/repository/proxy_probe_service.go b/backend/internal/repository/proxy_probe_service.go index 245eaade81..7ecb26cfdd 100644 --- a/backend/internal/repository/proxy_probe_service.go +++ b/backend/internal/repository/proxy_probe_service.go @@ -31,11 +31,27 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber { if insecure { log.Printf("[ProxyProbe] Warning: insecure_skip_verify is not allowed and will cause probe failure.") } + // 构建探测 URL 列表:优先用配置的自定义列表,否则用内置默认列表 + probeTargets := defaultProbeURLs + if cfg != nil && len(cfg.Security.ProxyProbe.URLs) > 0 { + probeTargets = make([]probeTarget, 0, len(cfg.Security.ProxyProbe.URLs)) + for _, u := range cfg.Security.ProxyProbe.URLs { + if strings.TrimSpace(u.URL) == "" || strings.TrimSpace(u.Parser) == "" { + continue + } + probeTargets = append(probeTargets, probeTarget{url: u.URL, parser: u.Parser}) + } + if len(probeTargets) == 0 { + probeTargets = defaultProbeURLs + } + } + return &proxyProbeService{ insecureSkipVerify: insecure, allowPrivateHosts: allowPrivate, validateResolvedIP: validateResolvedIP, maxResponseBytes: maxResponseBytes, + probeURLs: probeTargets, } } @@ -44,12 +60,16 @@ const ( defaultProxyProbeResponseMaxBytes = int64(1024 * 1024) ) -// probeURLs 按优先级排列的探测 URL 列表 -// 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选 -var probeURLs = []struct { +// probeTarget 描述一个探测端点及其响应解析方式。 +type probeTarget struct { url string - parser string // "ip-api" or "ipify" -}{ + parser string // "ip-api" / "ipify" / "chatgpt-trace" +} + +// defaultProbeURLs 按优先级排列的默认探测 URL 列表。 +// 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选。 +// 可通过配置 security.proxy_probe.urls 覆盖。 +var defaultProbeURLs = []probeTarget{ {"http://ip-api.com/json/?lang=zh-CN", "ip-api"}, {"http://api64.ipify.org?format=json", "ipify"}, } @@ -59,6 +79,7 @@ type proxyProbeService struct { allowPrivateHosts bool validateResolvedIP bool maxResponseBytes int64 + probeURLs []probeTarget } func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*service.ProxyExitInfo, int64, error) { @@ -74,6 +95,10 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s } var lastErr error + probeURLs := s.probeURLs + if len(probeURLs) == 0 { + probeURLs = defaultProbeURLs + } for _, probe := range probeURLs { exitInfo, latencyMs, err := s.probeWithURL(ctx, client, probe.url, probe.parser) if err == nil { @@ -121,6 +146,8 @@ func (s *proxyProbeService) probeWithURL(ctx context.Context, client *http.Clien return s.parseIPAPI(body, latencyMs) case "ipify": return s.parseIPify(body, latencyMs) + case "chatgpt-trace": + return s.parseChatGPTTrace(body, latencyMs) default: return nil, latencyMs, fmt.Errorf("unknown parser: %s", parser) } @@ -179,3 +206,35 @@ func (s *proxyProbeService) parseIPify(body []byte, latencyMs int64) (*service.P IP: result.IP, }, latencyMs, nil } + +// parseChatGPTTrace 解析 Cloudflare trace 端点(如 chatgpt.com/cdn-cgi/trace)的纯文本响应。 +// 响应按行给出键值对,其中 ip= 为出口 IP,loc= 为国家代码。 +func (s *proxyProbeService) parseChatGPTTrace(body []byte, latencyMs int64) (*service.ProxyExitInfo, int64, error) { + var ip, loc string + for _, line := range strings.Split(string(body), "\n") { + key, value, found := strings.Cut(strings.TrimSpace(line), "=") + if !found { + continue + } + switch key { + case "ip": + ip = strings.TrimSpace(value) + case "loc": + loc = strings.TrimSpace(value) + } + } + if ip == "" { + preview := string(body) + if len(preview) > 200 { + preview = preview[:200] + "..." + } + return nil, latencyMs, fmt.Errorf("chatgpt-trace: no ip= found in response (body: %s)", preview) + } + info := &service.ProxyExitInfo{ + IP: ip, + } + if loc != "" { + info.CountryCode = loc + } + return info, latencyMs, nil +} diff --git a/backend/internal/repository/proxy_probe_service_test.go b/backend/internal/repository/proxy_probe_service_test.go index c778f6c1eb..45c342cb4b 100644 --- a/backend/internal/repository/proxy_probe_service_test.go +++ b/backend/internal/repository/proxy_probe_service_test.go @@ -166,6 +166,22 @@ func (s *ProxyProbeServiceSuite) TestParseIPify_NoIP() { require.ErrorContains(s.T(), err, "no IP found") } +func (s *ProxyProbeServiceSuite) TestParseChatGPTTrace_Success() { + body := []byte("fl=abc\nh=chatgpt.com\nip=203.0.113.5\nts=1700000000\nloc=US\ntz=UTC\n") + info, latencyMs, err := s.prober.parseChatGPTTrace(body, 320) + require.NoError(s.T(), err) + require.Equal(s.T(), int64(320), latencyMs) + require.Equal(s.T(), "203.0.113.5", info.IP) + require.Equal(s.T(), "US", info.CountryCode) +} + +func (s *ProxyProbeServiceSuite) TestParseChatGPTTrace_NoIP() { + body := []byte("fl=abc\nh=chatgpt.com\nloc=US\n") + _, _, err := s.prober.parseChatGPTTrace(body, 100) + require.Error(s.T(), err) + require.ErrorContains(s.T(), err, "chatgpt-trace: no ip= found") +} + func TestProxyProbeServiceSuite(t *testing.T) { suite.Run(t, new(ProxyProbeServiceSuite)) } diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index e8c1c84b78..8f134dd609 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -186,6 +186,16 @@ security: # Allow skipping TLS verification for proxy probe (debug only) # 允许代理探测时跳过 TLS 证书验证(仅用于调试) insecure_skip_verify: false + # Optional ordered probe targets. Leave empty to use the built-in ip-api/ipify fallback. + # parser supports: ip-api, ipify, chatgpt-trace + # 可选的有序探测目标。留空时使用内置 ip-api/ipify 回退。 + # parser 支持:ip-api、ipify、chatgpt-trace + urls: [] + # urls: + # - url: "https://chatgpt.com/cdn-cgi/trace" + # parser: "chatgpt-trace" + # - url: "https://api64.ipify.org?format=json" + # parser: "ipify" proxy_fallback: # Allow auxiliary services (update check, pricing data) to fallback to direct # connection when proxy initialization fails. Does NOT affect AI gateway connections. From d5484866faf53d130fa11cbd5616b677d276c6e0 Mon Sep 17 00:00:00 2001 From: xuhaihan Date: Wed, 19 Aug 2026 19:52:10 +0800 Subject: [PATCH 2/4] fix(config): register proxy probe URLs default --- backend/internal/config/config.go | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 798c08017c..f6bafa1ee6 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2513,6 +2513,7 @@ func setEnvReachableDefaults() { viper.SetDefault("gateway.session_idle_timeout_minutes", 0) viper.SetDefault("gateway.user_message_queue.mode", "") viper.SetDefault("update.proxy_url", "") + viper.SetDefault("security.proxy_probe.urls", []ProbeURLConfig{}) // sticky_escape_enabled is the one exception to the zero-value rule: its // effective default is true, applied post-unmarshal via a viper.IsSet guard. From ec5a34593561518f974b0c3eff6d72808400194e Mon Sep 17 00:00:00 2001 From: xuhaihan Date: Wed, 19 Aug 2026 21:10:08 +0800 Subject: [PATCH 3/4] fix(proxy): validate configurable probe targets --- backend/internal/config/config.go | 43 ++++++++++++- .../internal/config/env_reachability_test.go | 16 ++++- .../config/proxy_probe_config_test.go | 47 ++++++++++++++ .../repository/proxy_probe_service.go | 61 ++++++++++--------- 4 files changed, 137 insertions(+), 30 deletions(-) create mode 100644 backend/internal/config/proxy_probe_config_test.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f6bafa1ee6..0fa0058a5d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -842,6 +842,43 @@ type ProbeURLConfig struct { Parser string `mapstructure:"parser"` // "ip-api" / "ipify" / "chatgpt-trace" } +func normalizeProxyProbeURLs(targets []ProbeURLConfig) ([]ProbeURLConfig, error) { + if len(targets) == 0 { + return nil, nil + } + + normalized := make([]ProbeURLConfig, 0, len(targets)) + for i, target := range targets { + rawURL := strings.TrimSpace(target.URL) + parser := strings.ToLower(strings.TrimSpace(target.Parser)) + if rawURL == "" { + return nil, fmt.Errorf("entry %d: url is required", i) + } + if parser == "" { + return nil, fmt.Errorf("entry %d: parser is required", i) + } + switch parser { + case "ip-api", "ipify", "chatgpt-trace": + default: + return nil, fmt.Errorf("entry %d: unsupported parser %q", i, target.Parser) + } + + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Host == "" { + return nil, fmt.Errorf("entry %d: invalid url %q", i, target.URL) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("entry %d: url scheme must be http or https", i) + } + + normalized = append(normalized, ProbeURLConfig{ + URL: rawURL, + Parser: parser, + }) + } + return normalized, nil +} + type BillingConfig struct { CircuitBreaker CircuitBreakerConfig `mapstructure:"circuit_breaker"` // MinimumBalanceReserve is the conservative preflight floor for balance billing. @@ -2513,7 +2550,6 @@ func setEnvReachableDefaults() { viper.SetDefault("gateway.session_idle_timeout_minutes", 0) viper.SetDefault("gateway.user_message_queue.mode", "") viper.SetDefault("update.proxy_url", "") - viper.SetDefault("security.proxy_probe.urls", []ProbeURLConfig{}) // sticky_escape_enabled is the one exception to the zero-value rule: its // effective default is true, applied post-unmarshal via a viper.IsSet guard. @@ -2580,6 +2616,11 @@ func (c *Config) Validate() error { } c.Security.ForwardedClientIPHeaders = forwardedClientIPHeaders c.SetForwardedClientIPSettings(c.Security.TrustForwardedIPForAPIKeyACL, forwardedClientIPHeaders) + proxyProbeURLs, err := normalizeProxyProbeURLs(c.Security.ProxyProbe.URLs) + if err != nil { + return fmt.Errorf("security.proxy_probe.urls: %w", err) + } + c.Security.ProxyProbe.URLs = proxyProbeURLs if c.Server.ReadHeaderTimeout < 1 || c.Server.ReadHeaderTimeout > 60 { return fmt.Errorf("server.read_header_timeout must be between 1 and 60 seconds") } diff --git a/backend/internal/config/env_reachability_test.go b/backend/internal/config/env_reachability_test.go index 8141a2ec3d..cfe39587ed 100644 --- a/backend/internal/config/env_reachability_test.go +++ b/backend/internal/config/env_reachability_test.go @@ -45,6 +45,20 @@ func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]strin // is out of scope here — such settings need a config file either way. continue } + if ft.Kind() == reflect.Slice { + elem := ft.Elem() + for elem.Kind() == reflect.Ptr { + elem = elem.Elem() + } + if elem.Kind() == reflect.Struct { + // AutomaticEnv exposes one string value. Viper's string-to-slice + // hook can populate scalar slices, but it cannot decode a string + // into []struct. Registering a default would turn silent ignore + // into a startup unmarshal error, so structured slices remain + // config-file-only just like maps. + continue + } + } out[strings.ToLower(key)] = ft.String() } } @@ -62,7 +76,7 @@ func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]strin // were lost, silently disabling async image tasks for env-driven deployments. // // When this fails, register a zero-valued default in setEnvReachableDefaults -// for each reported key. +// for each reported scalar key. Maps and slices of structs are config-file-only. func TestConfigKeysAreEnvReachable(t *testing.T) { bound := map[string]string{} collectMapstructureKeys(reflect.TypeOf(Config{}), "", bound) diff --git a/backend/internal/config/proxy_probe_config_test.go b/backend/internal/config/proxy_probe_config_test.go new file mode 100644 index 0000000000..af5b97209e --- /dev/null +++ b/backend/internal/config/proxy_probe_config_test.go @@ -0,0 +1,47 @@ +//go:build unit + +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeProxyProbeURLs(t *testing.T) { + t.Parallel() + + got, err := normalizeProxyProbeURLs([]ProbeURLConfig{ + {URL: " https://chatgpt.com/cdn-cgi/trace ", Parser: " CHATGPT-TRACE "}, + {URL: "https://api64.ipify.org?format=json", Parser: "ipify"}, + }) + require.NoError(t, err) + require.Equal(t, []ProbeURLConfig{ + {URL: "https://chatgpt.com/cdn-cgi/trace", Parser: "chatgpt-trace"}, + {URL: "https://api64.ipify.org?format=json", Parser: "ipify"}, + }, got) +} + +func TestNormalizeProxyProbeURLsRejectsInvalidEntries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + target ProbeURLConfig + wantErr string + }{ + {name: "missing URL", target: ProbeURLConfig{Parser: "ipify"}, wantErr: "url is required"}, + {name: "missing parser", target: ProbeURLConfig{URL: "https://example.com"}, wantErr: "parser is required"}, + {name: "unknown parser", target: ProbeURLConfig{URL: "https://example.com", Parser: "ip_api"}, wantErr: "unsupported parser"}, + {name: "relative URL", target: ProbeURLConfig{URL: "/cdn-cgi/trace", Parser: "chatgpt-trace"}, wantErr: "invalid url"}, + {name: "unsupported scheme", target: ProbeURLConfig{URL: "ftp://example.com/file", Parser: "ipify"}, wantErr: "scheme must be http or https"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := normalizeProxyProbeURLs([]ProbeURLConfig{tt.target}) + require.ErrorContains(t, err, tt.wantErr) + }) + } +} diff --git a/backend/internal/repository/proxy_probe_service.go b/backend/internal/repository/proxy_probe_service.go index 7ecb26cfdd..beae186bde 100644 --- a/backend/internal/repository/proxy_probe_service.go +++ b/backend/internal/repository/proxy_probe_service.go @@ -31,18 +31,15 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber { if insecure { log.Printf("[ProxyProbe] Warning: insecure_skip_verify is not allowed and will cause probe failure.") } - // 构建探测 URL 列表:优先用配置的自定义列表,否则用内置默认列表 - probeTargets := defaultProbeURLs + // 构建探测 URL 列表:配置存在时覆盖内置默认列表。 + var configuredTargets []configuredProbeTarget if cfg != nil && len(cfg.Security.ProxyProbe.URLs) > 0 { - probeTargets = make([]probeTarget, 0, len(cfg.Security.ProxyProbe.URLs)) + configuredTargets = make([]configuredProbeTarget, 0, len(cfg.Security.ProxyProbe.URLs)) for _, u := range cfg.Security.ProxyProbe.URLs { - if strings.TrimSpace(u.URL) == "" || strings.TrimSpace(u.Parser) == "" { - continue - } - probeTargets = append(probeTargets, probeTarget{url: u.URL, parser: u.Parser}) - } - if len(probeTargets) == 0 { - probeTargets = defaultProbeURLs + configuredTargets = append(configuredTargets, configuredProbeTarget{ + url: u.URL, + parser: u.Parser, + }) } } @@ -51,7 +48,7 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber { allowPrivateHosts: allowPrivate, validateResolvedIP: validateResolvedIP, maxResponseBytes: maxResponseBytes, - probeURLs: probeTargets, + configuredProbeURLs: configuredTargets, } } @@ -60,26 +57,27 @@ const ( defaultProxyProbeResponseMaxBytes = int64(1024 * 1024) ) -// probeTarget 描述一个探测端点及其响应解析方式。 -type probeTarget struct { - url string - parser string // "ip-api" / "ipify" / "chatgpt-trace" -} - -// defaultProbeURLs 按优先级排列的默认探测 URL 列表。 +// probeURLs 按优先级排列的内置探测 URL 列表。 // 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选。 -// 可通过配置 security.proxy_probe.urls 覆盖。 -var defaultProbeURLs = []probeTarget{ +var probeURLs = []struct { + url string + parser string +}{ {"http://ip-api.com/json/?lang=zh-CN", "ip-api"}, {"http://api64.ipify.org?format=json", "ipify"}, } +type configuredProbeTarget struct { + url string + parser string +} + type proxyProbeService struct { - insecureSkipVerify bool - allowPrivateHosts bool - validateResolvedIP bool - maxResponseBytes int64 - probeURLs []probeTarget + insecureSkipVerify bool + allowPrivateHosts bool + validateResolvedIP bool + maxResponseBytes int64 + configuredProbeURLs []configuredProbeTarget } func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*service.ProxyExitInfo, int64, error) { @@ -95,10 +93,17 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s } var lastErr error - probeURLs := s.probeURLs - if len(probeURLs) == 0 { - probeURLs = defaultProbeURLs + if len(s.configuredProbeURLs) > 0 { + for _, probe := range s.configuredProbeURLs { + 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) } + for _, probe := range probeURLs { exitInfo, latencyMs, err := s.probeWithURL(ctx, client, probe.url, probe.parser) if err == nil { From 1ab3256783b3c32bc5fca1446dabbd28b5153d61 Mon Sep 17 00:00:00 2001 From: xuhaihan Date: Wed, 19 Aug 2026 21:31:11 +0800 Subject: [PATCH 4/4] fix(proxy): format validated probe targets --- backend/internal/repository/proxy_probe_service.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/internal/repository/proxy_probe_service.go b/backend/internal/repository/proxy_probe_service.go index beae186bde..6088edfcc6 100644 --- a/backend/internal/repository/proxy_probe_service.go +++ b/backend/internal/repository/proxy_probe_service.go @@ -44,10 +44,10 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber { } return &proxyProbeService{ - insecureSkipVerify: insecure, - allowPrivateHosts: allowPrivate, - validateResolvedIP: validateResolvedIP, - maxResponseBytes: maxResponseBytes, + insecureSkipVerify: insecure, + allowPrivateHosts: allowPrivate, + validateResolvedIP: validateResolvedIP, + maxResponseBytes: maxResponseBytes, configuredProbeURLs: configuredTargets, } }