Skip to content

Commit fdcbf7a

Browse files
author
QTom
committed
feat(proxy): 集中代理 URL 验证并实现全局 fail-fast
提取 proxyurl.Parse() 公共包,将分散在 6 处的代理 URL 验证逻辑 统一收敛,确保无效代理配置在创建时立即失败,永不静默回退直连。 主要变更: - 新增 proxyurl 包:统一 TrimSpace → url.Parse → Host 校验 → Scheme 白名单 - socks5:// 自动升级为 socks5h://,防止 DNS 泄漏(大小写不敏感) - antigravity: http.ProxyURL → proxyutil.ConfigureTransportProxy 支持 SOCKS5 - openai_oauth: 删除 newOpenAIOAuthHTTPClient,收编至 httpclient.GetClient - 移除未使用的 ProxyStrict 字段(fail-fast 已是全局默认行为) - 补充 15 个 proxyurl 测试 + pricing/usage fail-fast 测试
1 parent 445bfdf commit fdcbf7a

31 files changed

Lines changed: 633 additions & 157 deletions

backend/internal/config/config.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,13 @@ type CSPConfig struct {
265265
}
266266

267267
type ProxyFallbackConfig struct {
268-
// AllowDirectOnError 当代理初始化失败时是否允许回退直连。
269-
// 默认 false:避免因代理配置错误导致 IP 泄露/关联。
268+
// AllowDirectOnError 当辅助服务的代理初始化失败时是否允许回退直连。
269+
// 仅影响以下非 AI 账号连接的辅助服务:
270+
// - GitHub Release 更新检查
271+
// - 定价数据拉取
272+
// 不影响 AI 账号网关连接(Claude/OpenAI/Gemini/Antigravity),
273+
// 这些关键路径的代理失败始终返回错误,不会回退直连。
274+
// 默认 false:避免因代理配置错误导致服务器真实 IP 泄露。
270275
AllowDirectOnError bool `mapstructure:"allow_direct_on_error"`
271276
}
272277

@@ -1105,6 +1110,9 @@ func setDefaults() {
11051110
viper.SetDefault("security.csp.policy", DefaultCSPPolicy)
11061111
viper.SetDefault("security.proxy_probe.insecure_skip_verify", false)
11071112

1113+
// Security - disable direct fallback on proxy error
1114+
viper.SetDefault("security.proxy_fallback.allow_direct_on_error", false)
1115+
11081116
// Billing
11091117
viper.SetDefault("billing.circuit_breaker.enabled", true)
11101118
viper.SetDefault("billing.circuit_breaker.failure_threshold", 5)
@@ -1415,9 +1423,6 @@ func setDefaults() {
14151423
viper.SetDefault("gemini.oauth.scopes", "")
14161424
viper.SetDefault("gemini.quota.policy", "")
14171425

1418-
// Security - proxy fallback
1419-
viper.SetDefault("security.proxy_fallback.allow_direct_on_error", false)
1420-
14211426
// Subscription Maintenance (bounded queue + worker pool)
14221427
viper.SetDefault("subscription_maintenance.worker_count", 2)
14231428
viper.SetDefault("subscription_maintenance.queue_size", 1024)

backend/internal/pkg/antigravity/client.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import (
1414
"net/url"
1515
"strings"
1616
"time"
17+
18+
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
19+
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
1720
)
1821

1922
// NewAPIRequestWithURL 使用指定的 base URL 创建 Antigravity API 请求(v1internal 端点)
@@ -149,22 +152,26 @@ type Client struct {
149152
httpClient *http.Client
150153
}
151154

152-
func NewClient(proxyURL string) *Client {
155+
func NewClient(proxyURL string) (*Client, error) {
153156
client := &http.Client{
154157
Timeout: 30 * time.Second,
155158
}
156159

157-
if strings.TrimSpace(proxyURL) != "" {
158-
if proxyURLParsed, err := url.Parse(proxyURL); err == nil {
159-
client.Transport = &http.Transport{
160-
Proxy: http.ProxyURL(proxyURLParsed),
161-
}
160+
_, parsed, err := proxyurl.Parse(proxyURL)
161+
if err != nil {
162+
return nil, err
163+
}
164+
if parsed != nil {
165+
transport := &http.Transport{}
166+
if err := proxyutil.ConfigureTransportProxy(transport, parsed); err != nil {
167+
return nil, fmt.Errorf("configure proxy: %w", err)
162168
}
169+
client.Transport = transport
163170
}
164171

165172
return &Client{
166173
httpClient: client,
167-
}
174+
}, nil
168175
}
169176

170177
// isConnectionError 判断是否为连接错误(网络超时、DNS 失败、连接拒绝)

backend/internal/pkg/antigravity/client_test.go

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,20 @@ func TestGetTier_两者都为nil(t *testing.T) {
228228
// NewClient
229229
// ---------------------------------------------------------------------------
230230

231+
func mustNewClient(t *testing.T, proxyURL string) *Client {
232+
t.Helper()
233+
client, err := NewClient(proxyURL)
234+
if err != nil {
235+
t.Fatalf("NewClient(%q) failed: %v", proxyURL, err)
236+
}
237+
return client
238+
}
239+
231240
func TestNewClient_无代理(t *testing.T) {
232-
client := NewClient("")
241+
client, err := NewClient("")
242+
if err != nil {
243+
t.Fatalf("NewClient 返回错误: %v", err)
244+
}
233245
if client == nil {
234246
t.Fatal("NewClient 返回 nil")
235247
}
@@ -246,7 +258,10 @@ func TestNewClient_无代理(t *testing.T) {
246258
}
247259

248260
func TestNewClient_有代理(t *testing.T) {
249-
client := NewClient("http://proxy.example.com:8080")
261+
client, err := NewClient("http://proxy.example.com:8080")
262+
if err != nil {
263+
t.Fatalf("NewClient 返回错误: %v", err)
264+
}
250265
if client == nil {
251266
t.Fatal("NewClient 返回 nil")
252267
}
@@ -256,7 +271,10 @@ func TestNewClient_有代理(t *testing.T) {
256271
}
257272

258273
func TestNewClient_空格代理(t *testing.T) {
259-
client := NewClient(" ")
274+
client, err := NewClient(" ")
275+
if err != nil {
276+
t.Fatalf("NewClient 返回错误: %v", err)
277+
}
260278
if client == nil {
261279
t.Fatal("NewClient 返回 nil")
262280
}
@@ -267,15 +285,13 @@ func TestNewClient_空格代理(t *testing.T) {
267285
}
268286

269287
func TestNewClient_无效代理URL(t *testing.T) {
270-
// 无效 URL 时 url.Parse 不一定返回错误(Go 的 url.Parse 很宽容),
271-
// 但 ://invalid 会导致解析错误
272-
client := NewClient("://invalid")
273-
if client == nil {
274-
t.Fatal("NewClient 返回 nil")
288+
// 无效 URL 应返回 error
289+
_, err := NewClient("://invalid")
290+
if err == nil {
291+
t.Fatal("无效代理 URL 应返回错误")
275292
}
276-
// 无效 URL 解析失败时,Transport 应保持 nil
277-
if client.httpClient.Transport != nil {
278-
t.Error("无效代理 URL 时 Transport 应为 nil")
293+
if !strings.Contains(err.Error(), "invalid proxy URL") {
294+
t.Errorf("错误信息应包含 'invalid proxy URL': got %s", err.Error())
279295
}
280296
}
281297

@@ -499,7 +515,7 @@ func TestClient_ExchangeCode_无ClientSecret(t *testing.T) {
499515
defaultClientSecret = ""
500516
t.Cleanup(func() { defaultClientSecret = old })
501517

502-
client := NewClient("")
518+
client := mustNewClient(t, "")
503519
_, err := client.ExchangeCode(context.Background(), "code", "verifier")
504520
if err == nil {
505521
t.Fatal("缺少 client_secret 时应返回错误")
@@ -602,7 +618,7 @@ func TestClient_RefreshToken_无ClientSecret(t *testing.T) {
602618
defaultClientSecret = ""
603619
t.Cleanup(func() { defaultClientSecret = old })
604620

605-
client := NewClient("")
621+
client := mustNewClient(t, "")
606622
_, err := client.RefreshToken(context.Background(), "refresh-tok")
607623
if err == nil {
608624
t.Fatal("缺少 client_secret 时应返回错误")
@@ -1242,7 +1258,7 @@ func TestClient_LoadCodeAssist_Success_RealCall(t *testing.T) {
12421258

12431259
withMockBaseURLs(t, []string{server.URL})
12441260

1245-
client := NewClient("")
1261+
client := mustNewClient(t, "")
12461262
resp, rawResp, err := client.LoadCodeAssist(context.Background(), "test-token")
12471263
if err != nil {
12481264
t.Fatalf("LoadCodeAssist 失败: %v", err)
@@ -1277,7 +1293,7 @@ func TestClient_LoadCodeAssist_HTTPError_RealCall(t *testing.T) {
12771293

12781294
withMockBaseURLs(t, []string{server.URL})
12791295

1280-
client := NewClient("")
1296+
client := mustNewClient(t, "")
12811297
_, _, err := client.LoadCodeAssist(context.Background(), "bad-token")
12821298
if err == nil {
12831299
t.Fatal("服务器返回 403 时应返回错误")
@@ -1300,7 +1316,7 @@ func TestClient_LoadCodeAssist_InvalidJSON_RealCall(t *testing.T) {
13001316

13011317
withMockBaseURLs(t, []string{server.URL})
13021318

1303-
client := NewClient("")
1319+
client := mustNewClient(t, "")
13041320
_, _, err := client.LoadCodeAssist(context.Background(), "token")
13051321
if err == nil {
13061322
t.Fatal("无效 JSON 响应应返回错误")
@@ -1333,7 +1349,7 @@ func TestClient_LoadCodeAssist_URLFallback_RealCall(t *testing.T) {
13331349

13341350
withMockBaseURLs(t, []string{server1.URL, server2.URL})
13351351

1336-
client := NewClient("")
1352+
client := mustNewClient(t, "")
13371353
resp, _, err := client.LoadCodeAssist(context.Background(), "token")
13381354
if err != nil {
13391355
t.Fatalf("LoadCodeAssist 应在 fallback 后成功: %v", err)
@@ -1361,7 +1377,7 @@ func TestClient_LoadCodeAssist_AllURLsFail_RealCall(t *testing.T) {
13611377

13621378
withMockBaseURLs(t, []string{server1.URL, server2.URL})
13631379

1364-
client := NewClient("")
1380+
client := mustNewClient(t, "")
13651381
_, _, err := client.LoadCodeAssist(context.Background(), "token")
13661382
if err == nil {
13671383
t.Fatal("所有 URL 都失败时应返回错误")
@@ -1377,7 +1393,7 @@ func TestClient_LoadCodeAssist_ContextCanceled_RealCall(t *testing.T) {
13771393

13781394
withMockBaseURLs(t, []string{server.URL})
13791395

1380-
client := NewClient("")
1396+
client := mustNewClient(t, "")
13811397
ctx, cancel := context.WithCancel(context.Background())
13821398
cancel()
13831399

@@ -1441,7 +1457,7 @@ func TestClient_FetchAvailableModels_Success_RealCall(t *testing.T) {
14411457

14421458
withMockBaseURLs(t, []string{server.URL})
14431459

1444-
client := NewClient("")
1460+
client := mustNewClient(t, "")
14451461
resp, rawResp, err := client.FetchAvailableModels(context.Background(), "test-token", "project-abc")
14461462
if err != nil {
14471463
t.Fatalf("FetchAvailableModels 失败: %v", err)
@@ -1496,7 +1512,7 @@ func TestClient_FetchAvailableModels_HTTPError_RealCall(t *testing.T) {
14961512

14971513
withMockBaseURLs(t, []string{server.URL})
14981514

1499-
client := NewClient("")
1515+
client := mustNewClient(t, "")
15001516
_, _, err := client.FetchAvailableModels(context.Background(), "bad-token", "proj")
15011517
if err == nil {
15021518
t.Fatal("服务器返回 403 时应返回错误")
@@ -1516,7 +1532,7 @@ func TestClient_FetchAvailableModels_InvalidJSON_RealCall(t *testing.T) {
15161532

15171533
withMockBaseURLs(t, []string{server.URL})
15181534

1519-
client := NewClient("")
1535+
client := mustNewClient(t, "")
15201536
_, _, err := client.FetchAvailableModels(context.Background(), "token", "proj")
15211537
if err == nil {
15221538
t.Fatal("无效 JSON 响应应返回错误")
@@ -1546,7 +1562,7 @@ func TestClient_FetchAvailableModels_URLFallback_RealCall(t *testing.T) {
15461562

15471563
withMockBaseURLs(t, []string{server1.URL, server2.URL})
15481564

1549-
client := NewClient("")
1565+
client := mustNewClient(t, "")
15501566
resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj")
15511567
if err != nil {
15521568
t.Fatalf("FetchAvailableModels 应在 fallback 后成功: %v", err)
@@ -1574,7 +1590,7 @@ func TestClient_FetchAvailableModels_AllURLsFail_RealCall(t *testing.T) {
15741590

15751591
withMockBaseURLs(t, []string{server1.URL, server2.URL})
15761592

1577-
client := NewClient("")
1593+
client := mustNewClient(t, "")
15781594
_, _, err := client.FetchAvailableModels(context.Background(), "token", "proj")
15791595
if err == nil {
15801596
t.Fatal("所有 URL 都失败时应返回错误")
@@ -1590,7 +1606,7 @@ func TestClient_FetchAvailableModels_ContextCanceled_RealCall(t *testing.T) {
15901606

15911607
withMockBaseURLs(t, []string{server.URL})
15921608

1593-
client := NewClient("")
1609+
client := mustNewClient(t, "")
15941610
ctx, cancel := context.WithCancel(context.Background())
15951611
cancel()
15961612

@@ -1610,7 +1626,7 @@ func TestClient_FetchAvailableModels_EmptyModels_RealCall(t *testing.T) {
16101626

16111627
withMockBaseURLs(t, []string{server.URL})
16121628

1613-
client := NewClient("")
1629+
client := mustNewClient(t, "")
16141630
resp, rawResp, err := client.FetchAvailableModels(context.Background(), "token", "proj")
16151631
if err != nil {
16161632
t.Fatalf("FetchAvailableModels 失败: %v", err)
@@ -1646,7 +1662,7 @@ func TestClient_LoadCodeAssist_408Fallback_RealCall(t *testing.T) {
16461662

16471663
withMockBaseURLs(t, []string{server1.URL, server2.URL})
16481664

1649-
client := NewClient("")
1665+
client := mustNewClient(t, "")
16501666
resp, _, err := client.LoadCodeAssist(context.Background(), "token")
16511667
if err != nil {
16521668
t.Fatalf("LoadCodeAssist 应在 408 fallback 后成功: %v", err)
@@ -1672,7 +1688,7 @@ func TestClient_FetchAvailableModels_404Fallback_RealCall(t *testing.T) {
16721688

16731689
withMockBaseURLs(t, []string{server1.URL, server2.URL})
16741690

1675-
client := NewClient("")
1691+
client := mustNewClient(t, "")
16761692
resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj")
16771693
if err != nil {
16781694
t.Fatalf("FetchAvailableModels 应在 404 fallback 后成功: %v", err)

backend/internal/pkg/httpclient/pool.go

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ package httpclient
1818
import (
1919
"fmt"
2020
"net/http"
21-
"net/url"
2221
"strings"
2322
"sync"
2423
"time"
2524

25+
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
2626
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
2727
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
2828
)
@@ -41,7 +41,6 @@ type Options struct {
4141
Timeout time.Duration // 请求总超时时间
4242
ResponseHeaderTimeout time.Duration // 等待响应头超时时间
4343
InsecureSkipVerify bool // 是否跳过 TLS 证书验证(已禁用,不允许设置为 true)
44-
ProxyStrict bool // 严格代理模式:代理失败时返回错误而非回退
4544
ValidateResolvedIP bool // 是否校验解析后的 IP(防止 DNS Rebinding)
4645
AllowPrivateHosts bool // 允许私有地址解析(与 ValidateResolvedIP 一起使用)
4746

@@ -120,15 +119,13 @@ func buildTransport(opts Options) (*http.Transport, error) {
120119
return nil, fmt.Errorf("insecure_skip_verify is not allowed; install a trusted certificate instead")
121120
}
122121

123-
proxyURL := strings.TrimSpace(opts.ProxyURL)
124-
if proxyURL == "" {
125-
return transport, nil
126-
}
127-
128-
parsed, err := url.Parse(proxyURL)
122+
_, parsed, err := proxyurl.Parse(opts.ProxyURL)
129123
if err != nil {
130124
return nil, err
131125
}
126+
if parsed == nil {
127+
return transport, nil
128+
}
132129

133130
if err := proxyutil.ConfigureTransportProxy(transport, parsed); err != nil {
134131
return nil, err
@@ -138,12 +135,11 @@ func buildTransport(opts Options) (*http.Transport, error) {
138135
}
139136

140137
func buildClientKey(opts Options) string {
141-
return fmt.Sprintf("%s|%s|%s|%t|%t|%t|%t|%d|%d|%d",
138+
return fmt.Sprintf("%s|%s|%s|%t|%t|%t|%d|%d|%d",
142139
strings.TrimSpace(opts.ProxyURL),
143140
opts.Timeout.String(),
144141
opts.ResponseHeaderTimeout.String(),
145142
opts.InsecureSkipVerify,
146-
opts.ProxyStrict,
147143
opts.ValidateResolvedIP,
148144
opts.AllowPrivateHosts,
149145
opts.MaxIdleConns,

0 commit comments

Comments
 (0)