feat(proxy): 统一代理配置并支持 SOCKS5H 协议

- 新增 proxyutil 包,统一 HTTP/HTTPS/SOCKS5/SOCKS5H 代理配置逻辑
- SOCKS5H 支持服务端 DNS 解析,避免本地 DNS 泄露
- 移除 ProxyStrict 宽松模式,代理失败直接返回错误不回退直连
- 前端代理管理页面支持 SOCKS5H 协议的添加/编辑/批量导入
- 补充 IPv6 地址和特殊字符密码的边界测试
This commit is contained in:
shaw
2026-01-04 11:43:58 +08:00
parent a11c71cea9
commit 70e9329e64
9 changed files with 303 additions and 44 deletions

View File

@@ -26,7 +26,7 @@ func NewProxyHandler(adminService service.AdminService) *ProxyHandler {
// CreateProxyRequest represents create proxy request // CreateProxyRequest represents create proxy request
type CreateProxyRequest struct { type CreateProxyRequest struct {
Name string `json:"name" binding:"required"` 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"` Host string `json:"host" binding:"required"`
Port int `json:"port" binding:"required,min=1,max=65535"` Port int `json:"port" binding:"required,min=1,max=65535"`
Username string `json:"username"` Username string `json:"username"`
@@ -36,7 +36,7 @@ type CreateProxyRequest struct {
// UpdateProxyRequest represents update proxy request // UpdateProxyRequest represents update proxy request
type UpdateProxyRequest struct { type UpdateProxyRequest struct {
Name string `json:"name"` 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"` Host string `json:"host"`
Port int `json:"port" binding:"omitempty,min=1,max=65535"` Port int `json:"port" binding:"omitempty,min=1,max=65535"`
Username string `json:"username"` Username string `json:"username"`
@@ -255,7 +255,7 @@ func (h *ProxyHandler) GetProxyAccounts(c *gin.Context) {
// BatchCreateProxyItem represents a single proxy in batch create request // BatchCreateProxyItem represents a single proxy in batch create request
type BatchCreateProxyItem struct { 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"` Host string `json:"host" binding:"required"`
Port int `json:"port" binding:"required,min=1,max=65535"` Port int `json:"port" binding:"required,min=1,max=65535"`
Username string `json:"username"` Username string `json:"username"`

View File

@@ -11,22 +11,20 @@
// 新实现使用统一的客户端池: // 新实现使用统一的客户端池:
// 1. 相同配置复用同一 http.Client 实例 // 1. 相同配置复用同一 http.Client 实例
// 2. 复用 Transport 连接池,减少 TCP/TLS 握手开销 // 2. 复用 Transport 连接池,减少 TCP/TLS 握手开销
// 3. 支持 HTTP/HTTPS/SOCKS5 代理 // 3. 支持 HTTP/HTTPS/SOCKS5/SOCKS5H 代理
// 4. 支持严格代理模式(代理失败则返回错误 // 4. 代理配置失败时直接返回错误,不会回退到直连(避免 IP 关联风险
package httpclient package httpclient
import ( import (
"context"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"sync" "sync"
"time" "time"
"golang.org/x/net/proxy" "github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
) )
// Transport 连接池默认配置 // Transport 连接池默认配置
@@ -38,11 +36,10 @@ const (
// Options 定义共享 HTTP 客户端的构建参数 // Options 定义共享 HTTP 客户端的构建参数
type Options struct { type Options struct {
ProxyURL string // 代理 URL支持 http/https/socks5 ProxyURL string // 代理 URL支持 http/https/socks5/socks5h
Timeout time.Duration // 请求总超时时间 Timeout time.Duration // 请求总超时时间
ResponseHeaderTimeout time.Duration // 等待响应头超时时间 ResponseHeaderTimeout time.Duration // 等待响应头超时时间
InsecureSkipVerify bool // 是否跳过 TLS 证书验证 InsecureSkipVerify bool // 是否跳过 TLS 证书验证
ProxyStrict bool // 严格代理模式:代理失败时返回错误而非回退
// 可选的连接池参数(不设置则使用默认值) // 可选的连接池参数(不设置则使用默认值)
MaxIdleConns int // 最大空闲连接总数(默认 100 MaxIdleConns int // 最大空闲连接总数(默认 100
@@ -55,6 +52,7 @@ var sharedClients sync.Map
// GetClient 返回共享的 HTTP 客户端实例 // GetClient 返回共享的 HTTP 客户端实例
// 性能优化:相同配置复用同一客户端,避免重复创建 Transport // 性能优化:相同配置复用同一客户端,避免重复创建 Transport
// 安全说明:代理配置失败时直接返回错误,不会回退到直连,避免 IP 关联风险
func GetClient(opts Options) (*http.Client, error) { func GetClient(opts Options) (*http.Client, error) {
key := buildClientKey(opts) key := buildClientKey(opts)
if cached, ok := sharedClients.Load(key); ok { if cached, ok := sharedClients.Load(key); ok {
@@ -65,12 +63,7 @@ func GetClient(opts Options) (*http.Client, error) {
client, err := buildClient(opts) client, err := buildClient(opts)
if err != nil { if err != nil {
if opts.ProxyStrict { return nil, err
return nil, err
}
fallback := opts
fallback.ProxyURL = ""
client, _ = buildClient(fallback)
} }
actual, _ := sharedClients.LoadOrStore(key, client) actual, _ := sharedClients.LoadOrStore(key, client)
@@ -125,31 +118,19 @@ func buildTransport(opts Options) (*http.Transport, error) {
return nil, err return nil, err
} }
switch strings.ToLower(parsed.Scheme) { if err := proxyutil.ConfigureTransportProxy(transport, parsed); err != nil {
case "http", "https": return nil, err
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)
} }
return transport, nil return transport, nil
} }
func buildClientKey(opts Options) string { 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), strings.TrimSpace(opts.ProxyURL),
opts.Timeout.String(), opts.Timeout.String(),
opts.ResponseHeaderTimeout.String(), opts.ResponseHeaderTimeout.String(),
opts.InsecureSkipVerify, opts.InsecureSkipVerify,
opts.ProxyStrict,
opts.MaxIdleConns, opts.MaxIdleConns,
opts.MaxIdleConnsPerHost, opts.MaxIdleConnsPerHost,
opts.MaxConnsPerHost, opts.MaxConnsPerHost,

View File

@@ -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)
}
}

View File

@@ -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")
})
}
}

View File

@@ -13,6 +13,7 @@ import (
"time" "time"
"github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
"github.com/Wei-Shaw/sub2api/internal/service" "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) 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{ entry := &upstreamClientEntry{
client: client, client: client,
proxyKey: proxyKey, proxyKey: proxyKey,
@@ -548,6 +554,7 @@ func defaultPoolSettings(cfg *config.Config) poolSettings {
// //
// 返回: // 返回:
// - *http.Transport: 配置好的 Transport 实例 // - *http.Transport: 配置好的 Transport 实例
// - error: 代理配置错误
// //
// Transport 参数说明: // Transport 参数说明:
// - MaxIdleConns: 所有主机的最大空闲连接总数 // - MaxIdleConns: 所有主机的最大空闲连接总数
@@ -555,7 +562,7 @@ func defaultPoolSettings(cfg *config.Config) poolSettings {
// - MaxConnsPerHost: 每主机最大连接数(达到后新请求等待) // - MaxConnsPerHost: 每主机最大连接数(达到后新请求等待)
// - IdleConnTimeout: 空闲连接超时(超时后关闭) // - IdleConnTimeout: 空闲连接超时(超时后关闭)
// - ResponseHeaderTimeout: 等待响应头超时(不影响流式传输) // - ResponseHeaderTimeout: 等待响应头超时(不影响流式传输)
func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL) *http.Transport { func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL) (*http.Transport, error) {
transport := &http.Transport{ transport := &http.Transport{
MaxIdleConns: settings.maxIdleConns, MaxIdleConns: settings.maxIdleConns,
MaxIdleConnsPerHost: settings.maxIdleConnsPerHost, MaxIdleConnsPerHost: settings.maxIdleConnsPerHost,
@@ -563,10 +570,10 @@ func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL) *http.Tran
IdleConnTimeout: settings.idleConnTimeout, IdleConnTimeout: settings.idleConnTimeout,
ResponseHeaderTimeout: settings.responseHeaderTimeout, ResponseHeaderTimeout: settings.responseHeaderTimeout,
} }
if proxyURL != nil { if err := proxyutil.ConfigureTransportProxy(transport, proxyURL); err != nil {
transport.Proxy = http.ProxyURL(proxyURL) return nil, err
} }
return transport return transport, nil
} }
// trackedBody 带跟踪功能的响应体包装器 // trackedBody 带跟踪功能的响应体包装器

View File

@@ -45,8 +45,12 @@ func BenchmarkHTTPUpstreamProxyClient(b *testing.B) {
settings := defaultPoolSettings(cfg) settings := defaultPoolSettings(cfg)
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
// 每次迭代都创建新客户端,包含 Transport 分配 // 每次迭代都创建新客户端,包含 Transport 分配
transport, err := buildUpstreamTransport(settings, parsedProxy)
if err != nil {
b.Fatalf("创建 Transport 失败: %v", err)
}
httpClientSink = &http.Client{ httpClientSink = &http.Client{
Transport: buildUpstreamTransport(settings, parsedProxy), Transport: transport,
} }
} }
}) })

View File

@@ -27,7 +27,6 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s
ProxyURL: proxyURL, ProxyURL: proxyURL,
Timeout: 15 * time.Second, Timeout: 15 * time.Second,
InsecureSkipVerify: true, InsecureSkipVerify: true,
ProxyStrict: true,
}) })
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("failed to create proxy client: %w", err) return nil, 0, fmt.Errorf("failed to create proxy client: %w", err)

View File

@@ -290,7 +290,7 @@ export interface UpdateGroupRequest {
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type AccountType = 'oauth' | 'setup-token' | 'apikey' export type AccountType = 'oauth' | 'setup-token' | 'apikey'
export type OAuthAddMethod = 'oauth' | 'setup-token' 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) // Claude Model type (returned by /v1/models and account models API)
export interface ClaudeModel { export interface ClaudeModel {

View File

@@ -90,7 +90,7 @@
<template #cell-protocol="{ value }"> <template #cell-protocol="{ value }">
<span <span
v-if="value" v-if="value"
:class="['badge', value === 'socks5' ? 'badge-primary' : 'badge-gray']" :class="['badge', value.startsWith('socks5') ? 'badge-primary' : 'badge-gray']"
> >
{{ value.toUpperCase() }} {{ value.toUpperCase() }}
</span> </span>
@@ -628,7 +628,8 @@ const protocolOptions = computed(() => [
{ value: '', label: t('admin.proxies.allProtocols') }, { value: '', label: t('admin.proxies.allProtocols') },
{ value: 'http', label: 'HTTP' }, { value: 'http', label: 'HTTP' },
{ value: 'https', label: 'HTTPS' }, { value: 'https', label: 'HTTPS' },
{ value: 'socks5', label: 'SOCKS5' } { value: 'socks5', label: 'SOCKS5' },
{ value: 'socks5h', label: 'SOCKS5H' }
]) ])
const statusOptions = computed(() => [ const statusOptions = computed(() => [
@@ -641,7 +642,8 @@ const statusOptions = computed(() => [
const protocolSelectOptions = [ const protocolSelectOptions = [
{ value: 'http', label: 'HTTP' }, { value: 'http', label: 'HTTP' },
{ value: 'https', label: 'HTTPS' }, { value: 'https', label: 'HTTPS' },
{ value: 'socks5', label: 'SOCKS5' } { value: 'socks5', label: 'SOCKS5' },
{ value: 'socks5h', label: 'SOCKS5H (服务端解析DNS)' }
] ]
const editStatusOptions = computed(() => [ const editStatusOptions = computed(() => [
@@ -798,8 +800,8 @@ const parseProxyUrl = (
const trimmed = line.trim() const trimmed = line.trim()
if (!trimmed) return null if (!trimmed) return null
// Regex to parse proxy URL // Regex to parse proxy URL (supports http, https, socks5, socks5h)
const regex = /^(https?|socks5):\/\/(?:([^:@]+):([^@]+)@)?([^:]+):(\d+)$/i const regex = /^(https?|socks5h?):\/\/(?:([^:@]+):([^@]+)@)?([^:]+):(\d+)$/i
const match = trimmed.match(regex) const match = trimmed.match(regex)
if (!match) return null if (!match) return null