Merge pull request #5834 from xuhaihan/feat/configurable-proxy-probe-targets

feat(proxy): allow configurable probe targets
This commit is contained in:
Wesley Liddick
2026-08-20 13:40:35 +08:00
committed by GitHub
6 changed files with 215 additions and 12 deletions
+52
View File
@@ -830,6 +830,53 @@ 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"
}
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 {
@@ -2569,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")
}
@@ -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)
@@ -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)
})
}
}
@@ -31,11 +31,24 @@ 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 列表:配置存在时覆盖内置默认列表。
var configuredTargets []configuredProbeTarget
if cfg != nil && len(cfg.Security.ProxyProbe.URLs) > 0 {
configuredTargets = make([]configuredProbeTarget, 0, len(cfg.Security.ProxyProbe.URLs))
for _, u := range cfg.Security.ProxyProbe.URLs {
configuredTargets = append(configuredTargets, configuredProbeTarget{
url: u.URL,
parser: u.Parser,
})
}
}
return &proxyProbeService{
insecureSkipVerify: insecure,
allowPrivateHosts: allowPrivate,
validateResolvedIP: validateResolvedIP,
maxResponseBytes: maxResponseBytes,
insecureSkipVerify: insecure,
allowPrivateHosts: allowPrivate,
validateResolvedIP: validateResolvedIP,
maxResponseBytes: maxResponseBytes,
configuredProbeURLs: configuredTargets,
}
}
@@ -44,21 +57,27 @@ const (
defaultProxyProbeResponseMaxBytes = int64(1024 * 1024)
)
// probeURLs 按优先级排列的探测 URL 列表
// 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选
// probeURLs 按优先级排列的内置探测 URL 列表。
// 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选。
var probeURLs = []struct {
url string
parser string // "ip-api" or "ipify"
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
insecureSkipVerify bool
allowPrivateHosts bool
validateResolvedIP bool
maxResponseBytes int64
configuredProbeURLs []configuredProbeTarget
}
func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*service.ProxyExitInfo, int64, error) {
@@ -74,6 +93,17 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s
}
var lastErr error
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 {
@@ -121,6 +151,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 +211,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
}
@@ -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))
}
+10
View File
@@ -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.