mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 16:08:02 +08:00
fix(proxy): validate configurable probe targets
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
Reference in New Issue
Block a user