diff --git a/backend/internal/handler/admin/proxy_handler.go b/backend/internal/handler/admin/proxy_handler.go index 0480b312..99557f9a 100644 --- a/backend/internal/handler/admin/proxy_handler.go +++ b/backend/internal/handler/admin/proxy_handler.go @@ -26,7 +26,7 @@ func NewProxyHandler(adminService service.AdminService) *ProxyHandler { // CreateProxyRequest represents create proxy request type CreateProxyRequest struct { Name string `json:"name" binding:"required"` - Protocol string `json:"protocol" binding:"required,oneof=http https socks5"` + Protocol string `json:"protocol" binding:"required,oneof=http https socks5 socks5h"` Host string `json:"host" binding:"required"` Port int `json:"port" binding:"required,min=1,max=65535"` Username string `json:"username"` @@ -36,7 +36,7 @@ type CreateProxyRequest struct { // UpdateProxyRequest represents update proxy request type UpdateProxyRequest struct { Name string `json:"name"` - Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5"` + Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"` Host string `json:"host"` Port int `json:"port" binding:"omitempty,min=1,max=65535"` Username string `json:"username"` @@ -255,7 +255,7 @@ func (h *ProxyHandler) GetProxyAccounts(c *gin.Context) { // BatchCreateProxyItem represents a single proxy in batch create request type BatchCreateProxyItem struct { - Protocol string `json:"protocol" binding:"required,oneof=http https socks5"` + Protocol string `json:"protocol" binding:"required,oneof=http https socks5 socks5h"` Host string `json:"host" binding:"required"` Port int `json:"port" binding:"required,min=1,max=65535"` Username string `json:"username"` diff --git a/backend/internal/pkg/httpclient/pool.go b/backend/internal/pkg/httpclient/pool.go index 1028fb84..8a81c09a 100644 --- a/backend/internal/pkg/httpclient/pool.go +++ b/backend/internal/pkg/httpclient/pool.go @@ -11,22 +11,20 @@ // 新实现使用统一的客户端池: // 1. 相同配置复用同一 http.Client 实例 // 2. 复用 Transport 连接池,减少 TCP/TLS 握手开销 -// 3. 支持 HTTP/HTTPS/SOCKS5 代理 -// 4. 支持严格代理模式(代理失败则返回错误) +// 3. 支持 HTTP/HTTPS/SOCKS5/SOCKS5H 代理 +// 4. 代理配置失败时直接返回错误,不会回退到直连(避免 IP 关联风险) package httpclient import ( - "context" "crypto/tls" "fmt" - "net" "net/http" "net/url" "strings" "sync" "time" - "golang.org/x/net/proxy" + "github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil" ) // Transport 连接池默认配置 @@ -38,11 +36,10 @@ const ( // Options 定义共享 HTTP 客户端的构建参数 type Options struct { - ProxyURL string // 代理 URL(支持 http/https/socks5) + ProxyURL string // 代理 URL(支持 http/https/socks5/socks5h) Timeout time.Duration // 请求总超时时间 ResponseHeaderTimeout time.Duration // 等待响应头超时时间 InsecureSkipVerify bool // 是否跳过 TLS 证书验证 - ProxyStrict bool // 严格代理模式:代理失败时返回错误而非回退 // 可选的连接池参数(不设置则使用默认值) MaxIdleConns int // 最大空闲连接总数(默认 100) @@ -55,6 +52,7 @@ var sharedClients sync.Map // GetClient 返回共享的 HTTP 客户端实例 // 性能优化:相同配置复用同一客户端,避免重复创建 Transport +// 安全说明:代理配置失败时直接返回错误,不会回退到直连,避免 IP 关联风险 func GetClient(opts Options) (*http.Client, error) { key := buildClientKey(opts) if cached, ok := sharedClients.Load(key); ok { @@ -65,12 +63,7 @@ func GetClient(opts Options) (*http.Client, error) { client, err := buildClient(opts) if err != nil { - if opts.ProxyStrict { - return nil, err - } - fallback := opts - fallback.ProxyURL = "" - client, _ = buildClient(fallback) + return nil, err } actual, _ := sharedClients.LoadOrStore(key, client) @@ -125,31 +118,19 @@ func buildTransport(opts Options) (*http.Transport, error) { return nil, err } - switch strings.ToLower(parsed.Scheme) { - case "http", "https": - transport.Proxy = http.ProxyURL(parsed) - case "socks5", "socks5h": - dialer, err := proxy.FromURL(parsed, proxy.Direct) - if err != nil { - return nil, err - } - transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { - return dialer.Dial(network, addr) - } - default: - return nil, fmt.Errorf("unsupported proxy protocol: %s", parsed.Scheme) + if err := proxyutil.ConfigureTransportProxy(transport, parsed); err != nil { + return nil, err } return transport, nil } func buildClientKey(opts Options) string { - return fmt.Sprintf("%s|%s|%s|%t|%t|%d|%d|%d", + return fmt.Sprintf("%s|%s|%s|%t|%d|%d|%d", strings.TrimSpace(opts.ProxyURL), opts.Timeout.String(), opts.ResponseHeaderTimeout.String(), opts.InsecureSkipVerify, - opts.ProxyStrict, opts.MaxIdleConns, opts.MaxIdleConnsPerHost, opts.MaxConnsPerHost, diff --git a/backend/internal/pkg/proxyutil/dialer.go b/backend/internal/pkg/proxyutil/dialer.go new file mode 100644 index 00000000..91b224a2 --- /dev/null +++ b/backend/internal/pkg/proxyutil/dialer.go @@ -0,0 +1,62 @@ +// Package proxyutil 提供统一的代理配置功能 +// +// 支持的代理协议: +// - HTTP/HTTPS: 通过 Transport.Proxy 设置 +// - SOCKS5/SOCKS5H: 通过 Transport.DialContext 设置(服务端解析 DNS) +package proxyutil + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "strings" + + "golang.org/x/net/proxy" +) + +// ConfigureTransportProxy 根据代理 URL 配置 Transport +// +// 支持的协议: +// - http/https: 设置 transport.Proxy +// - socks5/socks5h: 设置 transport.DialContext(由代理服务端解析 DNS) +// +// 参数: +// - transport: 需要配置的 http.Transport +// - proxyURL: 代理地址,nil 表示直连 +// +// 返回: +// - error: 代理配置错误(协议不支持或 dialer 创建失败) +func ConfigureTransportProxy(transport *http.Transport, proxyURL *url.URL) error { + if proxyURL == nil { + return nil + } + + scheme := strings.ToLower(proxyURL.Scheme) + switch scheme { + case "http", "https": + transport.Proxy = http.ProxyURL(proxyURL) + return nil + + case "socks5", "socks5h": + dialer, err := proxy.FromURL(proxyURL, proxy.Direct) + if err != nil { + return fmt.Errorf("create socks5 dialer: %w", err) + } + // 优先使用支持 context 的 DialContext,以支持请求取消和超时 + if contextDialer, ok := dialer.(proxy.ContextDialer); ok { + transport.DialContext = contextDialer.DialContext + } else { + // 回退路径:如果 dialer 不支持 ContextDialer,则包装为简单的 DialContext + // 注意:此回退不支持请求取消和超时控制 + transport.DialContext = func(_ context.Context, network, addr string) (net.Conn, error) { + return dialer.Dial(network, addr) + } + } + return nil + + default: + return fmt.Errorf("unsupported proxy scheme: %s", scheme) + } +} diff --git a/backend/internal/pkg/proxyutil/dialer_test.go b/backend/internal/pkg/proxyutil/dialer_test.go new file mode 100644 index 00000000..f153cc9f --- /dev/null +++ b/backend/internal/pkg/proxyutil/dialer_test.go @@ -0,0 +1,204 @@ +package proxyutil + +import ( + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigureTransportProxy_Nil(t *testing.T) { + transport := &http.Transport{} + err := ConfigureTransportProxy(transport, nil) + + require.NoError(t, err) + assert.Nil(t, transport.Proxy, "nil proxy should not set Proxy") + assert.Nil(t, transport.DialContext, "nil proxy should not set DialContext") +} + +func TestConfigureTransportProxy_HTTP(t *testing.T) { + transport := &http.Transport{} + proxyURL, _ := url.Parse("http://proxy.example.com:8080") + + err := ConfigureTransportProxy(transport, proxyURL) + + require.NoError(t, err) + assert.NotNil(t, transport.Proxy, "HTTP proxy should set Proxy") + assert.Nil(t, transport.DialContext, "HTTP proxy should not set DialContext") +} + +func TestConfigureTransportProxy_HTTPS(t *testing.T) { + transport := &http.Transport{} + proxyURL, _ := url.Parse("https://secure-proxy.example.com:8443") + + err := ConfigureTransportProxy(transport, proxyURL) + + require.NoError(t, err) + assert.NotNil(t, transport.Proxy, "HTTPS proxy should set Proxy") + assert.Nil(t, transport.DialContext, "HTTPS proxy should not set DialContext") +} + +func TestConfigureTransportProxy_SOCKS5(t *testing.T) { + transport := &http.Transport{} + proxyURL, _ := url.Parse("socks5://socks.example.com:1080") + + err := ConfigureTransportProxy(transport, proxyURL) + + require.NoError(t, err) + assert.Nil(t, transport.Proxy, "SOCKS5 proxy should not set Proxy") + assert.NotNil(t, transport.DialContext, "SOCKS5 proxy should set DialContext") +} + +func TestConfigureTransportProxy_SOCKS5H(t *testing.T) { + transport := &http.Transport{} + proxyURL, _ := url.Parse("socks5h://socks.example.com:1080") + + err := ConfigureTransportProxy(transport, proxyURL) + + require.NoError(t, err) + assert.Nil(t, transport.Proxy, "SOCKS5H proxy should not set Proxy") + assert.NotNil(t, transport.DialContext, "SOCKS5H proxy should set DialContext") +} + +func TestConfigureTransportProxy_CaseInsensitive(t *testing.T) { + testCases := []struct { + scheme string + useProxy bool // true = uses Transport.Proxy, false = uses DialContext + }{ + {"HTTP://proxy.example.com:8080", true}, + {"Http://proxy.example.com:8080", true}, + {"HTTPS://proxy.example.com:8443", true}, + {"Https://proxy.example.com:8443", true}, + {"SOCKS5://socks.example.com:1080", false}, + {"Socks5://socks.example.com:1080", false}, + {"SOCKS5H://socks.example.com:1080", false}, + {"Socks5h://socks.example.com:1080", false}, + } + + for _, tc := range testCases { + t.Run(tc.scheme, func(t *testing.T) { + transport := &http.Transport{} + proxyURL, _ := url.Parse(tc.scheme) + + err := ConfigureTransportProxy(transport, proxyURL) + + require.NoError(t, err) + if tc.useProxy { + assert.NotNil(t, transport.Proxy) + assert.Nil(t, transport.DialContext) + } else { + assert.Nil(t, transport.Proxy) + assert.NotNil(t, transport.DialContext) + } + }) + } +} + +func TestConfigureTransportProxy_Unsupported(t *testing.T) { + testCases := []string{ + "ftp://ftp.example.com", + "file:///path/to/file", + "unknown://example.com", + } + + for _, tc := range testCases { + t.Run(tc, func(t *testing.T) { + transport := &http.Transport{} + proxyURL, _ := url.Parse(tc) + + err := ConfigureTransportProxy(transport, proxyURL) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported proxy scheme") + }) + } +} + +func TestConfigureTransportProxy_WithAuth(t *testing.T) { + transport := &http.Transport{} + proxyURL, _ := url.Parse("socks5://user:password@socks.example.com:1080") + + err := ConfigureTransportProxy(transport, proxyURL) + + require.NoError(t, err) + assert.NotNil(t, transport.DialContext, "SOCKS5 with auth should set DialContext") +} + +func TestConfigureTransportProxy_EmptyScheme(t *testing.T) { + transport := &http.Transport{} + // 空 scheme 的 URL + proxyURL := &url.URL{Host: "proxy.example.com:8080"} + + err := ConfigureTransportProxy(transport, proxyURL) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported proxy scheme") +} + +func TestConfigureTransportProxy_PreservesExistingConfig(t *testing.T) { + // 验证代理配置不会覆盖 Transport 的其他配置 + transport := &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + } + proxyURL, _ := url.Parse("socks5://socks.example.com:1080") + + err := ConfigureTransportProxy(transport, proxyURL) + + require.NoError(t, err) + assert.Equal(t, 100, transport.MaxIdleConns, "MaxIdleConns should be preserved") + assert.Equal(t, 10, transport.MaxIdleConnsPerHost, "MaxIdleConnsPerHost should be preserved") + assert.NotNil(t, transport.DialContext, "DialContext should be set") +} + +func TestConfigureTransportProxy_IPv6(t *testing.T) { + testCases := []struct { + name string + proxyURL string + }{ + {"SOCKS5H with IPv6 loopback", "socks5h://[::1]:1080"}, + {"SOCKS5 with full IPv6", "socks5://[2001:db8::1]:1080"}, + {"HTTP with IPv6", "http://[::1]:8080"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + transport := &http.Transport{} + proxyURL, err := url.Parse(tc.proxyURL) + require.NoError(t, err, "URL should be parseable") + + err = ConfigureTransportProxy(transport, proxyURL) + require.NoError(t, err) + }) + } +} + +func TestConfigureTransportProxy_SpecialCharsInPassword(t *testing.T) { + testCases := []struct { + name string + proxyURL string + }{ + // 密码包含 @ 符号(URL 编码为 %40) + {"password with @", "socks5://user:p%40ssword@proxy.example.com:1080"}, + // 密码包含 : 符号(URL 编码为 %3A) + {"password with :", "socks5://user:pass%3Aword@proxy.example.com:1080"}, + // 密码包含 / 符号(URL 编码为 %2F) + {"password with /", "socks5://user:pass%2Fword@proxy.example.com:1080"}, + // 复杂密码 + {"complex password", "socks5h://admin:P%40ss%3Aw0rd%2F123@proxy.example.com:1080"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + transport := &http.Transport{} + proxyURL, err := url.Parse(tc.proxyURL) + require.NoError(t, err, "URL should be parseable") + + err = ConfigureTransportProxy(transport, proxyURL) + require.NoError(t, err) + assert.NotNil(t, transport.DialContext, "SOCKS5 should set DialContext") + }) + } +} diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index 180844b5..f0669979 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -13,6 +13,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil" "github.com/Wei-Shaw/sub2api/internal/service" ) @@ -225,7 +226,12 @@ func (s *httpUpstreamService) getClientEntry(proxyURL string, accountID int64, a // 缓存未命中或需要重建,创建新客户端 settings := s.resolvePoolSettings(isolation, accountConcurrency) - client := &http.Client{Transport: buildUpstreamTransport(settings, parsedProxy)} + transport, err := buildUpstreamTransport(settings, parsedProxy) + if err != nil { + s.mu.Unlock() + return nil, fmt.Errorf("build transport: %w", err) + } + client := &http.Client{Transport: transport} entry := &upstreamClientEntry{ client: client, proxyKey: proxyKey, @@ -548,6 +554,7 @@ func defaultPoolSettings(cfg *config.Config) poolSettings { // // 返回: // - *http.Transport: 配置好的 Transport 实例 +// - error: 代理配置错误 // // Transport 参数说明: // - MaxIdleConns: 所有主机的最大空闲连接总数 @@ -555,7 +562,7 @@ func defaultPoolSettings(cfg *config.Config) poolSettings { // - MaxConnsPerHost: 每主机最大连接数(达到后新请求等待) // - IdleConnTimeout: 空闲连接超时(超时后关闭) // - ResponseHeaderTimeout: 等待响应头超时(不影响流式传输) -func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL) *http.Transport { +func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL) (*http.Transport, error) { transport := &http.Transport{ MaxIdleConns: settings.maxIdleConns, MaxIdleConnsPerHost: settings.maxIdleConnsPerHost, @@ -563,10 +570,10 @@ func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL) *http.Tran IdleConnTimeout: settings.idleConnTimeout, ResponseHeaderTimeout: settings.responseHeaderTimeout, } - if proxyURL != nil { - transport.Proxy = http.ProxyURL(proxyURL) + if err := proxyutil.ConfigureTransportProxy(transport, proxyURL); err != nil { + return nil, err } - return transport + return transport, nil } // trackedBody 带跟踪功能的响应体包装器 diff --git a/backend/internal/repository/http_upstream_benchmark_test.go b/backend/internal/repository/http_upstream_benchmark_test.go index 3219c6da..1e7430a3 100644 --- a/backend/internal/repository/http_upstream_benchmark_test.go +++ b/backend/internal/repository/http_upstream_benchmark_test.go @@ -45,8 +45,12 @@ func BenchmarkHTTPUpstreamProxyClient(b *testing.B) { settings := defaultPoolSettings(cfg) for i := 0; i < b.N; i++ { // 每次迭代都创建新客户端,包含 Transport 分配 + transport, err := buildUpstreamTransport(settings, parsedProxy) + if err != nil { + b.Fatalf("创建 Transport 失败: %v", err) + } httpClientSink = &http.Client{ - Transport: buildUpstreamTransport(settings, parsedProxy), + Transport: transport, } } }) diff --git a/backend/internal/repository/proxy_probe_service.go b/backend/internal/repository/proxy_probe_service.go index 8b288c3c..f5f625f9 100644 --- a/backend/internal/repository/proxy_probe_service.go +++ b/backend/internal/repository/proxy_probe_service.go @@ -27,7 +27,6 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s ProxyURL: proxyURL, Timeout: 15 * time.Second, InsecureSkipVerify: true, - ProxyStrict: true, }) if err != nil { return nil, 0, fmt.Errorf("failed to create proxy client: %w", err) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a3e4c25e..2f6aa2c8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -290,7 +290,7 @@ export interface UpdateGroupRequest { export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' export type AccountType = 'oauth' | 'setup-token' | 'apikey' export type OAuthAddMethod = 'oauth' | 'setup-token' -export type ProxyProtocol = 'http' | 'https' | 'socks5' +export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h' // Claude Model type (returned by /v1/models and account models API) export interface ClaudeModel { diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue index a5df9bd0..613b503c 100644 --- a/frontend/src/views/admin/ProxiesView.vue +++ b/frontend/src/views/admin/ProxiesView.vue @@ -90,7 +90,7 @@