From d13e1dcaa3262dc58031252c1f95862f6bc95b43 Mon Sep 17 00:00:00 2001 From: dxiaom <1473919287@qq.com> Date: Sat, 10 Oct 2026 05:44:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E6=B8=A0=E9=81=93=E4=BB=A3?= =?UTF-8?q?=E7=90=86=20-=20=E6=A0=A1=E9=AA=8C=E4=BB=A3=E7=90=86=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E5=B9=B6=E6=8B=92=E7=BB=9D=E6=97=A0=E6=95=88=E5=87=BA?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR: #717 PR 标题: feat(admin): 渠道管理 - 模型供应商支持渠道级 SOCKS5/HTTP 代理 校验渠道级代理地址并在历史无效配置下拒绝出站请求,保留空代理回退全局代理的行为,同时补充管理端和出站层回归测试。 Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com> --- backend/internal/app/admin.go | 6 ++- backend/internal/app/admin_channel_test.go | 19 ++++++++++ backend/internal/app/channel_outbound.go | 2 +- backend/internal/app/outbound_alias.go | 5 +++ backend/internal/model/models_channel.go | 6 +-- backend/internal/outbound/outbound.go | 37 +++++++++++++++++-- backend/internal/outbound/outbound_test.go | 19 ++++++++++ backend/internal/service/aliases_funcs.go | 1 + .../pages/admin/channels/channels-page.tsx | 2 +- 9 files changed, 88 insertions(+), 9 deletions(-) diff --git a/backend/internal/app/admin.go b/backend/internal/app/admin.go index aae99a2a2..fced35e71 100644 --- a/backend/internal/app/admin.go +++ b/backend/internal/app/admin.go @@ -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 } diff --git a/backend/internal/app/admin_channel_test.go b/backend/internal/app/admin_channel_test.go index 7712e658b..d16525f1c 100644 --- a/backend/internal/app/admin_channel_test.go +++ b/backend/internal/app/admin_channel_test.go @@ -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) diff --git a/backend/internal/app/channel_outbound.go b/backend/internal/app/channel_outbound.go index 9753dd734..aeec51acd 100644 --- a/backend/internal/app/channel_outbound.go +++ b/backend/internal/app/channel_outbound.go @@ -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) } diff --git a/backend/internal/app/outbound_alias.go b/backend/internal/app/outbound_alias.go index be699048b..a221f9d91 100644 --- a/backend/internal/app/outbound_alias.go +++ b/backend/internal/app/outbound_alias.go @@ -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) } diff --git a/backend/internal/model/models_channel.go b/backend/internal/model/models_channel.go index 4c5f66f50..d34034070 100644 --- a/backend/internal/model/models_channel.go +++ b/backend/internal/model/models_channel.go @@ -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"` diff --git a/backend/internal/outbound/outbound.go b/backend/internal/outbound/outbound.go index 9f11f95c6..0dbe749dc 100644 --- a/backend/internal/outbound/outbound.go +++ b/backend/internal/outbound/outbound.go @@ -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) diff --git a/backend/internal/outbound/outbound_test.go b/backend/internal/outbound/outbound_test.go index 41a7c0f5f..e856c5252 100644 --- a/backend/internal/outbound/outbound_test.go +++ b/backend/internal/outbound/outbound_test.go @@ -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 { diff --git a/backend/internal/service/aliases_funcs.go b/backend/internal/service/aliases_funcs.go index 7fe6f35a2..335f2e0fc 100644 --- a/backend/internal/service/aliases_funcs.go +++ b/backend/internal/service/aliases_funcs.go @@ -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 diff --git a/web/src/pages/admin/channels/channels-page.tsx b/web/src/pages/admin/channels/channels-page.tsx index d001ab62d..e2ec86ed0 100644 --- a/web/src/pages/admin/channels/channels-page.tsx +++ b/web/src/pages/admin/channels/channels-page.tsx @@ -399,7 +399,7 @@ export default function ChannelsPage() {