fix(admin): 渠道代理 - 校验代理地址并拒绝无效出口
PR: #717 PR 标题: feat(admin): 渠道管理 - 模型供应商支持渠道级 SOCKS5/HTTP 代理 校验渠道级代理地址并在历史无效配置下拒绝出站请求,保留空代理回退全局代理的行为,同时补充管理端和出站层回归测试。 Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
@@ -189,7 +189,11 @@ func (s *Service) channelFromRequest(req ChannelRequest, channel model.ModelChan
|
||||
}
|
||||
channel.ModelsJSON = string(modelsJSON)
|
||||
channel.HeadersJSON = headersJSON
|
||||
channel.ProxyURL = strings.TrimSpace(req.ProxyURL)
|
||||
proxyURL := strings.TrimSpace(req.ProxyURL)
|
||||
if _, err := ValidateProxyURL(proxyURL); err != nil {
|
||||
return channel, err
|
||||
}
|
||||
channel.ProxyURL = proxyURL
|
||||
if req.Enabled != nil {
|
||||
channel.Enabled = *req.Enabled
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"yingce/backend/internal/model"
|
||||
@@ -189,6 +190,24 @@ func TestChannelFromRequestStoresAndClearsHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelFromRequestValidatesProxyURL(t *testing.T) {
|
||||
limit := 4
|
||||
base := model.ModelChannel{}
|
||||
channel, err := channelFromRequest(ChannelRequest{Name: "Proxy", BaseURL: "https://93.184.216.34/v1", ProxyURL: "socks5://user:secret@127.0.0.1:1080", ConcurrencyLimit: &limit}, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if channel.ProxyURL != "socks5://user:secret@127.0.0.1:1080" {
|
||||
t.Fatalf("proxy URL = %q", channel.ProxyURL)
|
||||
}
|
||||
for _, proxyURL := range []string{"ftp://127.0.0.1:1080", "socks5://", "socks5://127.0.0.1:1080#fragment", "http://" + strings.Repeat("a", 506)} {
|
||||
_, err := channelFromRequest(ChannelRequest{Name: "Proxy", BaseURL: "https://93.184.216.34/v1", ProxyURL: proxyURL, ConcurrencyLimit: &limit}, base)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid proxy URL %q was accepted", proxyURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicChannelOnlyReturnsSystemHeadersAndProxyToAdmin(t *testing.T) {
|
||||
channel := model.ModelChannel{ID: "system-1", Scope: model.ChannelScopeSystem, BaseURL: "https://example.com/v1", HeadersJSON: `[{"name":"X-Gateway-Tenant","value":"tenant-a"}]`, ProxyURL: "socks5://user:secret@127.0.0.1:1080"}
|
||||
adminView := publicChannel(channel, true, nil)
|
||||
|
||||
@@ -13,7 +13,7 @@ func (s *Service) ValidateChannelOutboundURL(rawURL string) (*url.URL, error) {
|
||||
}
|
||||
|
||||
// OutboundHTTPClientForChannel 返回渠道专用的出站 HTTP 客户端。
|
||||
// proxyURL 非空时使用渠道级代理(socks5/http/https),否则回退到全局客户端。
|
||||
// proxyURL 非空时使用渠道级代理(socks5/socks5h/http/https),否则回退到全局客户端。
|
||||
func (s *Service) OutboundHTTPClientForChannel(timeout time.Duration, _ *url.URL, proxyURL string) *http.Client {
|
||||
return OutboundHTTPClientWithProxy(timeout, proxyURL)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ func ValidateCustomRelayURL(rawURL string) (*url.URL, error) {
|
||||
return parsed, mapOutboundError(err)
|
||||
}
|
||||
|
||||
func ValidateProxyURL(rawURL string) (*url.URL, error) {
|
||||
parsed, err := outbound.ValidateProxyURL(rawURL)
|
||||
return parsed, mapOutboundError(err)
|
||||
}
|
||||
|
||||
func OutboundHTTPClient(timeout time.Duration) *http.Client {
|
||||
return outbound.OutboundHTTPClient(timeout)
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ type ModelChannel struct {
|
||||
SecretKey string `json:"-"`
|
||||
APIFormat string `json:"apiFormat" gorm:"size:24"`
|
||||
ConcurrencyLimit int `json:"concurrencyLimit"`
|
||||
// ProxyURL 是渠道级出站代理地址,支持 socks5://、http://、https://。
|
||||
// ProxyURL 是渠道级出站代理地址,支持 socks5://、socks5h://、http://、https://。
|
||||
// 留空时使用全局环境变量代理。
|
||||
ProxyURL string `json:"proxyUrl" gorm:"size:512"`
|
||||
ModelsJSON string `json:"modelsJson" gorm:"type:text"`
|
||||
ProxyURL string `json:"proxyUrl" gorm:"size:512"`
|
||||
ModelsJSON string `json:"modelsJson" gorm:"type:text"`
|
||||
// RetiredModelsJSON 记录已被一个模型家族吸收的上游 SKU,防止目录拉取时重新创建重复记录。
|
||||
RetiredModelsJSON string `json:"-" gorm:"type:text"`
|
||||
HeadersJSON string `json:"-" gorm:"type:text"`
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
maxOutboundRedirects = 5
|
||||
maxOutboundHeaderCount = 32
|
||||
maxOutboundHeaderBytes = 16 << 10
|
||||
maxProxyURLBytes = 512
|
||||
CustomRelayHeadersHeader = "X-Canvas-Upstream-Headers"
|
||||
DefaultOutboundUserAgent = "CanvasViewport/1.0 (+https://github.com/ddcat-ai/open-ai-canvas)"
|
||||
)
|
||||
@@ -96,6 +97,30 @@ func ValidateCustomRelayURL(rawURL string) (*url.URL, error) {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// ValidateProxyURL 校验渠道级代理地址。代理可以使用认证信息和私网地址,
|
||||
// 因为它是管理员配置的出口,而不是用户可控的上游 URL。
|
||||
func ValidateProxyURL(rawURL string) (*url.URL, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if len(rawURL) > maxProxyURLBytes {
|
||||
return nil, BadAuthRequest("渠道代理地址过长")
|
||||
}
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || !parsed.IsAbs() || parsed.Hostname() == "" {
|
||||
return nil, BadAuthRequest("渠道代理地址无效")
|
||||
}
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
if scheme != "socks5" && scheme != "socks5h" && scheme != "http" && scheme != "https" {
|
||||
return nil, BadAuthRequest("渠道代理只支持 socks5/socks5h/http/https")
|
||||
}
|
||||
if parsed.Fragment != "" {
|
||||
return nil, BadAuthRequest("渠道代理地址不允许包含片段")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func OutboundHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: outboundTransport,
|
||||
@@ -119,7 +144,7 @@ func OutboundHTTPClientWithProxy(timeout time.Duration, proxyURL string) *http.C
|
||||
}
|
||||
transport := newOutboundTransportWithProxy(proxyURL)
|
||||
if transport == nil {
|
||||
return OutboundHTTPClient(timeout)
|
||||
return &http.Client{Transport: rejectingTransport{err: errors.New("渠道代理地址无效")}, Timeout: timeout}
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
@@ -134,6 +159,12 @@ func OutboundHTTPClientWithProxy(timeout time.Duration, proxyURL string) *http.C
|
||||
}
|
||||
}
|
||||
|
||||
type rejectingTransport struct{ err error }
|
||||
|
||||
func (t rejectingTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, t.err
|
||||
}
|
||||
|
||||
func CustomRelayHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: customRelayTransport,
|
||||
@@ -315,8 +346,8 @@ func outboundProxyFromEnvironment(req *http.Request) (*url.URL, error) {
|
||||
// 本地 DNS 在 TUN/VPN 模式下会返回 fake-ip,若先本地解析再经代
|
||||
// 理连接会导致远端拿到不可达的假 IP;交给远端解析可避免此问题。
|
||||
func newOutboundTransportWithProxy(proxyURL string) *http.Transport {
|
||||
parsed, err := url.Parse(proxyURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
parsed, err := ValidateProxyURL(proxyURL)
|
||||
if err != nil || parsed == nil {
|
||||
return nil
|
||||
}
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -46,6 +47,24 @@ func TestConfiguredProxyHostIsTrustedAsDeploymentEgress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundHTTPClientWithProxyFailsClosedForInvalidURL(t *testing.T) {
|
||||
client := OutboundHTTPClientWithProxy(time.Second, "ftp://127.0.0.1:1080")
|
||||
request, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.Do(request); err == nil || !strings.Contains(err.Error(), "渠道代理地址无效") {
|
||||
t.Fatalf("invalid proxy should fail closed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundHTTPClientWithProxyFallsBackForEmptyURL(t *testing.T) {
|
||||
client := OutboundHTTPClientWithProxy(time.Second, " \t")
|
||||
if client.Transport != outboundTransport {
|
||||
t.Fatal("empty proxy URL should use the global outbound transport")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOutboundHeadersAllowsCustomUserAgent(t *testing.T) {
|
||||
headers, err := NormalizeOutboundHeaders([]OutboundHeader{{Name: "user-agent", Value: "Custom Gateway/2.0"}, {Name: "X-Gateway-Tenant", Value: "tenant-a"}})
|
||||
if err != nil {
|
||||
|
||||
@@ -60,6 +60,7 @@ var (
|
||||
ValidateCapabilitySpec = app.ValidateCapabilitySpec
|
||||
ValidateChannelModelPrice = app.ValidateChannelModelPrice
|
||||
ValidateCustomRelayURL = app.ValidateCustomRelayURL
|
||||
ValidateProxyURL = app.ValidateProxyURL
|
||||
ValidateLogicalModelPrice = app.ValidateLogicalModelPrice
|
||||
ValidateOutboundHost = app.ValidateOutboundHost
|
||||
ValidateOutboundURL = app.ValidateOutboundURL
|
||||
|
||||
@@ -399,7 +399,7 @@ export default function ChannelsPage() {
|
||||
<Form.Item
|
||||
name="proxyUrl"
|
||||
label="代理地址"
|
||||
extra="为该渠道单独配置出站代理,支持 socks5://、http://、https://。留空则使用全局环境变量代理。"
|
||||
extra="为该渠道单独配置出站代理,支持 socks5://、socks5h://、http://、https://。留空则使用全局环境变量代理。"
|
||||
>
|
||||
<Input placeholder="例如:socks5://127.0.0.1:1080 或 http://127.0.0.1:7890" />
|
||||
</Form.Item>
|
||||
|
||||
Reference in New Issue
Block a user