Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
// Package proxyutil 提供统一的代理配置功能
|
||||
//
|
||||
// 支持的代理协议:
|
||||
// - HTTP/HTTPS: 通过 Transport.Proxy 设置
|
||||
// - SOCKS5: 通过 Transport.DialContext 设置(客户端本地解析 DNS)
|
||||
// - SOCKS5H: 通过 Transport.DialContext 设置(代理端远程解析 DNS,推荐)
|
||||
//
|
||||
// 注意:proxyurl.Parse() 会自动将 socks5:// 升级为 socks5h://,
|
||||
// 确保 DNS 也由代理端解析,防止 DNS 泄漏。
|
||||
package proxyutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
// socks5DialTimeout 限制到 SOCKS5 代理自身的 TCP 建连耗时。
|
||||
socks5DialTimeout = 10 * time.Second
|
||||
// socks5DialKeepAlive 与 Go 默认 keepalive 探测间隔保持一致。
|
||||
socks5DialKeepAlive = 30 * time.Second
|
||||
)
|
||||
|
||||
// socks5ForwardDialer 是 SOCKS5 dialer 的底层拨号器。
|
||||
//
|
||||
// proxy.FromURL 的默认 forward dialer 是 proxy.Direct(零值 net.Dialer,无超时),
|
||||
// 代理地址不可达时会一直卡到内核 TCP 重传耗尽(Linux 约 130 秒)。SOCKS5 分支会
|
||||
// 覆盖 Transport.DialContext,因此调用方在 Transport 上设置的建连超时对这条路径
|
||||
// 无效,必须在这里补上。
|
||||
var socks5ForwardDialer = &net.Dialer{
|
||||
Timeout: socks5DialTimeout,
|
||||
KeepAlive: socks5DialKeepAlive,
|
||||
}
|
||||
|
||||
// ConfigureTransportProxy 根据代理 URL 配置 Transport
|
||||
//
|
||||
// 支持的协议:
|
||||
// - http/https: 设置 transport.Proxy
|
||||
// - socks5: 设置 transport.DialContext(客户端本地解析 DNS)
|
||||
// - 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, socks5ForwardDialer)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package proxyutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var errStub = errors.New("stub dial")
|
||||
|
||||
// 回归:SOCKS5 分支覆盖了调用方在 Transport 上设置的 DialContext,
|
||||
// 底层 forward dialer 必须自带建连超时。proxy.Direct 是零值 net.Dialer,
|
||||
// 代理不可达时会一直卡到内核 TCP 重传耗尽(Linux 约 130 秒)。
|
||||
func TestSOCKS5ForwardDialerHasBoundedTimeout(t *testing.T) {
|
||||
require.Greater(t, socks5ForwardDialer.Timeout, time.Duration(0))
|
||||
require.Equal(t, socks5DialTimeout, socks5ForwardDialer.Timeout)
|
||||
require.Equal(t, socks5DialKeepAlive, socks5ForwardDialer.KeepAlive)
|
||||
}
|
||||
|
||||
func TestConfigureTransportProxySOCKS5SetsDialContext(t *testing.T) {
|
||||
for _, scheme := range []string{"socks5", "socks5h"} {
|
||||
t.Run(scheme, func(t *testing.T) {
|
||||
proxyURL, err := url.Parse(scheme + "://127.0.0.1:1080")
|
||||
require.NoError(t, err)
|
||||
|
||||
transport := &http.Transport{}
|
||||
require.NoError(t, ConfigureTransportProxy(transport, proxyURL))
|
||||
require.NotNil(t, transport.DialContext)
|
||||
require.Nil(t, transport.Proxy, "SOCKS5 不应设置 Transport.Proxy")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP 代理走 Transport.Proxy,不得覆盖调用方设置的 DialContext。
|
||||
func TestConfigureTransportProxyHTTPPreservesDialContext(t *testing.T) {
|
||||
proxyURL, err := url.Parse("http://127.0.0.1:8080")
|
||||
require.NoError(t, err)
|
||||
|
||||
called := false
|
||||
transport := &http.Transport{}
|
||||
transport.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||
called = true
|
||||
return nil, errStub
|
||||
}
|
||||
|
||||
require.NoError(t, ConfigureTransportProxy(transport, proxyURL))
|
||||
require.NotNil(t, transport.Proxy)
|
||||
require.NotNil(t, transport.DialContext)
|
||||
|
||||
_, _ = transport.DialContext(context.Background(), "tcp", "127.0.0.1:1")
|
||||
require.True(t, called, "HTTP 代理分支不应替换调用方的 DialContext")
|
||||
}
|
||||
Reference in New Issue
Block a user