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

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