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 测试
This commit is contained in:
QTom
2026-03-02 15:53:26 +08:00
parent 445bfdf242
commit fdcbf7aacf
31 changed files with 633 additions and 157 deletions

View File

@@ -14,6 +14,9 @@ import (
"net/url"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
)
// NewAPIRequestWithURL 使用指定的 base URL 创建 Antigravity API 请求v1internal 端点)
@@ -149,22 +152,26 @@ type Client struct {
httpClient *http.Client
}
func NewClient(proxyURL string) *Client {
func NewClient(proxyURL string) (*Client, error) {
client := &http.Client{
Timeout: 30 * time.Second,
}
if strings.TrimSpace(proxyURL) != "" {
if proxyURLParsed, err := url.Parse(proxyURL); err == nil {
client.Transport = &http.Transport{
Proxy: http.ProxyURL(proxyURLParsed),
}
_, parsed, err := proxyurl.Parse(proxyURL)
if err != nil {
return nil, err
}
if parsed != nil {
transport := &http.Transport{}
if err := proxyutil.ConfigureTransportProxy(transport, parsed); err != nil {
return nil, fmt.Errorf("configure proxy: %w", err)
}
client.Transport = transport
}
return &Client{
httpClient: client,
}
}, nil
}
// isConnectionError 判断是否为连接错误网络超时、DNS 失败、连接拒绝)

View File

@@ -228,8 +228,20 @@ func TestGetTier_两者都为nil(t *testing.T) {
// NewClient
// ---------------------------------------------------------------------------
func mustNewClient(t *testing.T, proxyURL string) *Client {
t.Helper()
client, err := NewClient(proxyURL)
if err != nil {
t.Fatalf("NewClient(%q) failed: %v", proxyURL, err)
}
return client
}
func TestNewClient_无代理(t *testing.T) {
client := NewClient("")
client, err := NewClient("")
if err != nil {
t.Fatalf("NewClient 返回错误: %v", err)
}
if client == nil {
t.Fatal("NewClient 返回 nil")
}
@@ -246,7 +258,10 @@ func TestNewClient_无代理(t *testing.T) {
}
func TestNewClient_有代理(t *testing.T) {
client := NewClient("http://proxy.example.com:8080")
client, err := NewClient("http://proxy.example.com:8080")
if err != nil {
t.Fatalf("NewClient 返回错误: %v", err)
}
if client == nil {
t.Fatal("NewClient 返回 nil")
}
@@ -256,7 +271,10 @@ func TestNewClient_有代理(t *testing.T) {
}
func TestNewClient_空格代理(t *testing.T) {
client := NewClient(" ")
client, err := NewClient(" ")
if err != nil {
t.Fatalf("NewClient 返回错误: %v", err)
}
if client == nil {
t.Fatal("NewClient 返回 nil")
}
@@ -267,15 +285,13 @@ func TestNewClient_空格代理(t *testing.T) {
}
func TestNewClient_无效代理URL(t *testing.T) {
// 无效 URL 时 url.Parse 不一定返回错误Go 的 url.Parse 很宽容),
// 但 ://invalid 会导致解析错误
client := NewClient("://invalid")
if client == nil {
t.Fatal("NewClient 返回 nil")
// 无效 URL 应返回 error
_, err := NewClient("://invalid")
if err == nil {
t.Fatal("无效代理 URL 应返回错误")
}
// 无效 URL 解析失败时Transport 应保持 nil
if client.httpClient.Transport != nil {
t.Error("无效代理 URL 时 Transport 应为 nil")
if !strings.Contains(err.Error(), "invalid proxy URL") {
t.Errorf("错误信息应包含 'invalid proxy URL': got %s", err.Error())
}
}
@@ -499,7 +515,7 @@ func TestClient_ExchangeCode_无ClientSecret(t *testing.T) {
defaultClientSecret = ""
t.Cleanup(func() { defaultClientSecret = old })
client := NewClient("")
client := mustNewClient(t, "")
_, err := client.ExchangeCode(context.Background(), "code", "verifier")
if err == nil {
t.Fatal("缺少 client_secret 时应返回错误")
@@ -602,7 +618,7 @@ func TestClient_RefreshToken_无ClientSecret(t *testing.T) {
defaultClientSecret = ""
t.Cleanup(func() { defaultClientSecret = old })
client := NewClient("")
client := mustNewClient(t, "")
_, err := client.RefreshToken(context.Background(), "refresh-tok")
if err == nil {
t.Fatal("缺少 client_secret 时应返回错误")
@@ -1242,7 +1258,7 @@ func TestClient_LoadCodeAssist_Success_RealCall(t *testing.T) {
withMockBaseURLs(t, []string{server.URL})
client := NewClient("")
client := mustNewClient(t, "")
resp, rawResp, err := client.LoadCodeAssist(context.Background(), "test-token")
if err != nil {
t.Fatalf("LoadCodeAssist 失败: %v", err)
@@ -1277,7 +1293,7 @@ func TestClient_LoadCodeAssist_HTTPError_RealCall(t *testing.T) {
withMockBaseURLs(t, []string{server.URL})
client := NewClient("")
client := mustNewClient(t, "")
_, _, err := client.LoadCodeAssist(context.Background(), "bad-token")
if err == nil {
t.Fatal("服务器返回 403 时应返回错误")
@@ -1300,7 +1316,7 @@ func TestClient_LoadCodeAssist_InvalidJSON_RealCall(t *testing.T) {
withMockBaseURLs(t, []string{server.URL})
client := NewClient("")
client := mustNewClient(t, "")
_, _, err := client.LoadCodeAssist(context.Background(), "token")
if err == nil {
t.Fatal("无效 JSON 响应应返回错误")
@@ -1333,7 +1349,7 @@ func TestClient_LoadCodeAssist_URLFallback_RealCall(t *testing.T) {
withMockBaseURLs(t, []string{server1.URL, server2.URL})
client := NewClient("")
client := mustNewClient(t, "")
resp, _, err := client.LoadCodeAssist(context.Background(), "token")
if err != nil {
t.Fatalf("LoadCodeAssist 应在 fallback 后成功: %v", err)
@@ -1361,7 +1377,7 @@ func TestClient_LoadCodeAssist_AllURLsFail_RealCall(t *testing.T) {
withMockBaseURLs(t, []string{server1.URL, server2.URL})
client := NewClient("")
client := mustNewClient(t, "")
_, _, err := client.LoadCodeAssist(context.Background(), "token")
if err == nil {
t.Fatal("所有 URL 都失败时应返回错误")
@@ -1377,7 +1393,7 @@ func TestClient_LoadCodeAssist_ContextCanceled_RealCall(t *testing.T) {
withMockBaseURLs(t, []string{server.URL})
client := NewClient("")
client := mustNewClient(t, "")
ctx, cancel := context.WithCancel(context.Background())
cancel()
@@ -1441,7 +1457,7 @@ func TestClient_FetchAvailableModels_Success_RealCall(t *testing.T) {
withMockBaseURLs(t, []string{server.URL})
client := NewClient("")
client := mustNewClient(t, "")
resp, rawResp, err := client.FetchAvailableModels(context.Background(), "test-token", "project-abc")
if err != nil {
t.Fatalf("FetchAvailableModels 失败: %v", err)
@@ -1496,7 +1512,7 @@ func TestClient_FetchAvailableModels_HTTPError_RealCall(t *testing.T) {
withMockBaseURLs(t, []string{server.URL})
client := NewClient("")
client := mustNewClient(t, "")
_, _, err := client.FetchAvailableModels(context.Background(), "bad-token", "proj")
if err == nil {
t.Fatal("服务器返回 403 时应返回错误")
@@ -1516,7 +1532,7 @@ func TestClient_FetchAvailableModels_InvalidJSON_RealCall(t *testing.T) {
withMockBaseURLs(t, []string{server.URL})
client := NewClient("")
client := mustNewClient(t, "")
_, _, err := client.FetchAvailableModels(context.Background(), "token", "proj")
if err == nil {
t.Fatal("无效 JSON 响应应返回错误")
@@ -1546,7 +1562,7 @@ func TestClient_FetchAvailableModels_URLFallback_RealCall(t *testing.T) {
withMockBaseURLs(t, []string{server1.URL, server2.URL})
client := NewClient("")
client := mustNewClient(t, "")
resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj")
if err != nil {
t.Fatalf("FetchAvailableModels 应在 fallback 后成功: %v", err)
@@ -1574,7 +1590,7 @@ func TestClient_FetchAvailableModels_AllURLsFail_RealCall(t *testing.T) {
withMockBaseURLs(t, []string{server1.URL, server2.URL})
client := NewClient("")
client := mustNewClient(t, "")
_, _, err := client.FetchAvailableModels(context.Background(), "token", "proj")
if err == nil {
t.Fatal("所有 URL 都失败时应返回错误")
@@ -1590,7 +1606,7 @@ func TestClient_FetchAvailableModels_ContextCanceled_RealCall(t *testing.T) {
withMockBaseURLs(t, []string{server.URL})
client := NewClient("")
client := mustNewClient(t, "")
ctx, cancel := context.WithCancel(context.Background())
cancel()
@@ -1610,7 +1626,7 @@ func TestClient_FetchAvailableModels_EmptyModels_RealCall(t *testing.T) {
withMockBaseURLs(t, []string{server.URL})
client := NewClient("")
client := mustNewClient(t, "")
resp, rawResp, err := client.FetchAvailableModels(context.Background(), "token", "proj")
if err != nil {
t.Fatalf("FetchAvailableModels 失败: %v", err)
@@ -1646,7 +1662,7 @@ func TestClient_LoadCodeAssist_408Fallback_RealCall(t *testing.T) {
withMockBaseURLs(t, []string{server1.URL, server2.URL})
client := NewClient("")
client := mustNewClient(t, "")
resp, _, err := client.LoadCodeAssist(context.Background(), "token")
if err != nil {
t.Fatalf("LoadCodeAssist 应在 408 fallback 后成功: %v", err)
@@ -1672,7 +1688,7 @@ func TestClient_FetchAvailableModels_404Fallback_RealCall(t *testing.T) {
withMockBaseURLs(t, []string{server1.URL, server2.URL})
client := NewClient("")
client := mustNewClient(t, "")
resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj")
if err != nil {
t.Fatalf("FetchAvailableModels 应在 404 fallback 后成功: %v", err)

View File

@@ -18,11 +18,11 @@ package httpclient
import (
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
)
@@ -41,7 +41,6 @@ type Options struct {
Timeout time.Duration // 请求总超时时间
ResponseHeaderTimeout time.Duration // 等待响应头超时时间
InsecureSkipVerify bool // 是否跳过 TLS 证书验证(已禁用,不允许设置为 true
ProxyStrict bool // 严格代理模式:代理失败时返回错误而非回退
ValidateResolvedIP bool // 是否校验解析后的 IP防止 DNS Rebinding
AllowPrivateHosts bool // 允许私有地址解析(与 ValidateResolvedIP 一起使用)
@@ -120,15 +119,13 @@ func buildTransport(opts Options) (*http.Transport, error) {
return nil, fmt.Errorf("insecure_skip_verify is not allowed; install a trusted certificate instead")
}
proxyURL := strings.TrimSpace(opts.ProxyURL)
if proxyURL == "" {
return transport, nil
}
parsed, err := url.Parse(proxyURL)
_, parsed, err := proxyurl.Parse(opts.ProxyURL)
if err != nil {
return nil, err
}
if parsed == nil {
return transport, nil
}
if err := proxyutil.ConfigureTransportProxy(transport, parsed); err != nil {
return nil, err
@@ -138,12 +135,11 @@ func buildTransport(opts Options) (*http.Transport, error) {
}
func buildClientKey(opts Options) string {
return fmt.Sprintf("%s|%s|%s|%t|%t|%t|%t|%d|%d|%d",
return fmt.Sprintf("%s|%s|%s|%t|%t|%t|%d|%d|%d",
strings.TrimSpace(opts.ProxyURL),
opts.Timeout.String(),
opts.ResponseHeaderTimeout.String(),
opts.InsecureSkipVerify,
opts.ProxyStrict,
opts.ValidateResolvedIP,
opts.AllowPrivateHosts,
opts.MaxIdleConns,

View File

@@ -0,0 +1,66 @@
// Package proxyurl 提供代理 URL 的统一验证fail-fast无效代理不回退直连
//
// 所有需要解析代理 URL 的地方必须通过此包的 Parse 函数。
// 直接使用 url.Parse 处理代理 URL 是被禁止的。
// 这确保了 fail-fast 行为:无效代理配置在创建时立即失败,
// 而不是在运行时静默回退到直连(产生 IP 关联风险)。
package proxyurl
import (
"fmt"
"net/url"
"strings"
)
// allowedSchemes 代理协议白名单
var allowedSchemes = map[string]bool{
"http": true,
"https": true,
"socks5": true,
"socks5h": true,
}
// Parse 解析并验证代理 URL。
//
// 语义:
// - 空字符串 → ("", nil, nil),表示直连
// - 非空且有效 → (trimmed, *url.URL, nil)
// - 非空但无效 → ("", nil, error)fail-fast 不回退
//
// 验证规则:
// - TrimSpace 后为空视为直连
// - url.Parse 失败返回 error不含原始 URL防凭据泄露
// - Host 为空返回 error用 Redacted() 脱敏)
// - Scheme 必须为 http/https/socks5/socks5h
// - socks5:// 自动升级为 socks5h://(确保 DNS 由代理端解析,防止 DNS 泄漏)
func Parse(raw string) (trimmed string, parsed *url.URL, err error) {
trimmed = strings.TrimSpace(raw)
if trimmed == "" {
return "", nil, nil
}
parsed, err = url.Parse(trimmed)
if err != nil {
// 不使用 %w 包装,避免 url.Parse 的底层错误消息泄漏原始 URL可能含凭据
return "", nil, fmt.Errorf("invalid proxy URL: %v", err)
}
if parsed.Host == "" || parsed.Hostname() == "" {
return "", nil, fmt.Errorf("proxy URL missing host: %s", parsed.Redacted())
}
scheme := strings.ToLower(parsed.Scheme)
if !allowedSchemes[scheme] {
return "", nil, fmt.Errorf("unsupported proxy scheme %q (allowed: http, https, socks5, socks5h)", scheme)
}
// 自动升级 socks5 → socks5h确保 DNS 由代理端解析,防止 DNS 泄漏。
// Go 的 golang.org/x/net/proxy 对 socks5:// 默认在客户端本地解析 DNS
// 仅 socks5h:// 才将域名发送给代理端做远程 DNS 解析。
if scheme == "socks5" {
parsed.Scheme = "socks5h"
trimmed = parsed.String()
}
return trimmed, parsed, nil
}

View File

@@ -0,0 +1,215 @@
package proxyurl
import (
"strings"
"testing"
)
func TestParse_空字符串直连(t *testing.T) {
trimmed, parsed, err := Parse("")
if err != nil {
t.Fatalf("空字符串应直连: %v", err)
}
if trimmed != "" {
t.Errorf("trimmed 应为空: got %q", trimmed)
}
if parsed != nil {
t.Errorf("parsed 应为 nil: got %v", parsed)
}
}
func TestParse_空白字符串直连(t *testing.T) {
trimmed, parsed, err := Parse(" ")
if err != nil {
t.Fatalf("空白字符串应直连: %v", err)
}
if trimmed != "" {
t.Errorf("trimmed 应为空: got %q", trimmed)
}
if parsed != nil {
t.Errorf("parsed 应为 nil: got %v", parsed)
}
}
func TestParse_有效HTTP代理(t *testing.T) {
trimmed, parsed, err := Parse("http://proxy.example.com:8080")
if err != nil {
t.Fatalf("有效 HTTP 代理应成功: %v", err)
}
if trimmed != "http://proxy.example.com:8080" {
t.Errorf("trimmed 不匹配: got %q", trimmed)
}
if parsed == nil {
t.Fatal("parsed 不应为 nil")
}
if parsed.Host != "proxy.example.com:8080" {
t.Errorf("Host 不匹配: got %q", parsed.Host)
}
}
func TestParse_有效HTTPS代理(t *testing.T) {
_, parsed, err := Parse("https://proxy.example.com:443")
if err != nil {
t.Fatalf("有效 HTTPS 代理应成功: %v", err)
}
if parsed.Scheme != "https" {
t.Errorf("Scheme 不匹配: got %q", parsed.Scheme)
}
}
func TestParse_有效SOCKS5代理_自动升级为SOCKS5H(t *testing.T) {
trimmed, parsed, err := Parse("socks5://127.0.0.1:1080")
if err != nil {
t.Fatalf("有效 SOCKS5 代理应成功: %v", err)
}
// socks5 自动升级为 socks5h确保 DNS 由代理端解析
if trimmed != "socks5h://127.0.0.1:1080" {
t.Errorf("trimmed 应升级为 socks5h: got %q", trimmed)
}
if parsed.Scheme != "socks5h" {
t.Errorf("Scheme 应升级为 socks5h: got %q", parsed.Scheme)
}
}
func TestParse_无效URL(t *testing.T) {
_, _, err := Parse("://invalid")
if err == nil {
t.Fatal("无效 URL 应返回错误")
}
if !strings.Contains(err.Error(), "invalid proxy URL") {
t.Errorf("错误信息应包含 'invalid proxy URL': got %s", err.Error())
}
}
func TestParse_缺少Host(t *testing.T) {
_, _, err := Parse("http://")
if err == nil {
t.Fatal("缺少 host 应返回错误")
}
if !strings.Contains(err.Error(), "missing host") {
t.Errorf("错误信息应包含 'missing host': got %s", err.Error())
}
}
func TestParse_不支持的Scheme(t *testing.T) {
_, _, err := Parse("ftp://proxy.example.com:21")
if err == nil {
t.Fatal("不支持的 scheme 应返回错误")
}
if !strings.Contains(err.Error(), "unsupported proxy scheme") {
t.Errorf("错误信息应包含 'unsupported proxy scheme': got %s", err.Error())
}
}
func TestParse_含密码URL脱敏(t *testing.T) {
// 场景 1: 带密码的 socks5 URL 应成功解析并升级为 socks5h
trimmed, parsed, err := Parse("socks5://user:secret_password@proxy.local:1080")
if err != nil {
t.Fatalf("含密码的有效 URL 应成功: %v", err)
}
if trimmed == "" || parsed == nil {
t.Fatal("应返回非空结果")
}
if parsed.Scheme != "socks5h" {
t.Errorf("Scheme 应升级为 socks5h: got %q", parsed.Scheme)
}
if !strings.HasPrefix(trimmed, "socks5h://") {
t.Errorf("trimmed 应以 socks5h:// 开头: got %q", trimmed)
}
if parsed.User == nil {
t.Error("升级后应保留 UserInfo")
}
// 场景 2: 带密码但缺少 host触发 Redacted 脱敏路径)
_, _, err = Parse("http://user:secret_password@:0/")
if err == nil {
t.Fatal("缺少 host 应返回错误")
}
if strings.Contains(err.Error(), "secret_password") {
t.Error("错误信息不应包含明文密码")
}
if !strings.Contains(err.Error(), "missing host") {
t.Errorf("错误信息应包含 'missing host': got %s", err.Error())
}
}
func TestParse_带空白的有效URL(t *testing.T) {
trimmed, parsed, err := Parse(" http://proxy.example.com:8080 ")
if err != nil {
t.Fatalf("带空白的有效 URL 应成功: %v", err)
}
if trimmed != "http://proxy.example.com:8080" {
t.Errorf("trimmed 应去除空白: got %q", trimmed)
}
if parsed == nil {
t.Fatal("parsed 不应为 nil")
}
}
func TestParse_Scheme大小写不敏感(t *testing.T) {
// 大写 SOCKS5 应被接受并升级为 socks5h
trimmed, parsed, err := Parse("SOCKS5://proxy.example.com:1080")
if err != nil {
t.Fatalf("大写 SOCKS5 应被接受: %v", err)
}
if parsed.Scheme != "socks5h" {
t.Errorf("大写 SOCKS5 Scheme 应升级为 socks5h: got %q", parsed.Scheme)
}
if !strings.HasPrefix(trimmed, "socks5h://") {
t.Errorf("大写 SOCKS5 trimmed 应升级为 socks5h://: got %q", trimmed)
}
// 大写 HTTP 应被接受(不变)
_, _, err = Parse("HTTP://proxy.example.com:8080")
if err != nil {
t.Fatalf("大写 HTTP 应被接受: %v", err)
}
}
func TestParse_带认证的有效代理(t *testing.T) {
trimmed, parsed, err := Parse("http://user:pass@proxy.example.com:8080")
if err != nil {
t.Fatalf("带认证的代理 URL 应成功: %v", err)
}
if parsed.User == nil {
t.Error("应保留 UserInfo")
}
if trimmed != "http://user:pass@proxy.example.com:8080" {
t.Errorf("trimmed 不匹配: got %q", trimmed)
}
}
func TestParse_IPv6地址(t *testing.T) {
trimmed, parsed, err := Parse("http://[::1]:8080")
if err != nil {
t.Fatalf("IPv6 代理 URL 应成功: %v", err)
}
if parsed.Hostname() != "::1" {
t.Errorf("Hostname 不匹配: got %q", parsed.Hostname())
}
if trimmed != "http://[::1]:8080" {
t.Errorf("trimmed 不匹配: got %q", trimmed)
}
}
func TestParse_SOCKS5H保持不变(t *testing.T) {
trimmed, parsed, err := Parse("socks5h://proxy.local:1080")
if err != nil {
t.Fatalf("有效 SOCKS5H 代理应成功: %v", err)
}
// socks5h 不需要升级,应保持原样
if trimmed != "socks5h://proxy.local:1080" {
t.Errorf("trimmed 不应变化: got %q", trimmed)
}
if parsed.Scheme != "socks5h" {
t.Errorf("Scheme 应保持 socks5h: got %q", parsed.Scheme)
}
}
func TestParse_无Scheme裸地址(t *testing.T) {
// 无 scheme 的裸地址Go url.Parse 将其视为 pathHost 为空
_, _, err := Parse("proxy.example.com:8080")
if err == nil {
t.Fatal("无 scheme 的裸地址应返回错误")
}
}

View File

@@ -2,7 +2,11 @@
//
// 支持的代理协议:
// - HTTP/HTTPS: 通过 Transport.Proxy 设置
// - SOCKS5/SOCKS5H: 通过 Transport.DialContext 设置(服务端解析 DNS
// - SOCKS5: 通过 Transport.DialContext 设置(客户端本地解析 DNS
// - SOCKS5H: 通过 Transport.DialContext 设置(代理端远程解析 DNS推荐
//
// 注意proxyurl.Parse() 会自动将 socks5:// 升级为 socks5h://
// 确保 DNS 也由代理端解析,防止 DNS 泄漏。
package proxyutil
import (
@@ -20,7 +24,8 @@ import (
//
// 支持的协议:
// - http/https: 设置 transport.Proxy
// - socks5/socks5h: 设置 transport.DialContext由代理服务端解析 DNS
// - socks5: 设置 transport.DialContext客户端本地解析 DNS
// - socks5h: 设置 transport.DialContext代理端远程解析 DNS推荐
//
// 参数:
// - transport: 需要配置的 http.Transport