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:
@@ -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 失败、连接拒绝)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
66
backend/internal/pkg/proxyurl/parse.go
Normal file
66
backend/internal/pkg/proxyurl/parse.go
Normal 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
|
||||
}
|
||||
215
backend/internal/pkg/proxyurl/parse_test.go
Normal file
215
backend/internal/pkg/proxyurl/parse_test.go
Normal 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 将其视为 path,Host 为空
|
||||
_, _, err := Parse("proxy.example.com:8080")
|
||||
if err == nil {
|
||||
t.Fatal("无 scheme 的裸地址应返回错误")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user