fix(admin): 渠道代理 - 校验代理地址并拒绝无效出口

PR: #717
PR 标题: feat(admin): 渠道管理 - 模型供应商支持渠道级 SOCKS5/HTTP 代理

校验渠道级代理地址并在历史无效配置下拒绝出站请求,保留空代理回退全局代理的行为,同时补充管理端和出站层回归测试。

Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
dxiaom
2026-10-10 05:44:48 +08:00
committed by ddcat666
parent 524d2910ca
commit d13e1dcaa3
9 changed files with 88 additions and 9 deletions

View File

@@ -189,7 +189,11 @@ func (s *Service) channelFromRequest(req ChannelRequest, channel model.ModelChan
} }
channel.ModelsJSON = string(modelsJSON) channel.ModelsJSON = string(modelsJSON)
channel.HeadersJSON = headersJSON 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 { if req.Enabled != nil {
channel.Enabled = *req.Enabled channel.Enabled = *req.Enabled
} }

View File

@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv" "strconv"
"strings"
"testing" "testing"
"yingce/backend/internal/model" "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) { 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"} 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) adminView := publicChannel(channel, true, nil)

View File

@@ -13,7 +13,7 @@ func (s *Service) ValidateChannelOutboundURL(rawURL string) (*url.URL, error) {
} }
// OutboundHTTPClientForChannel 返回渠道专用的出站 HTTP 客户端。 // OutboundHTTPClientForChannel 返回渠道专用的出站 HTTP 客户端。
// proxyURL 非空时使用渠道级代理(socks5/http/https),否则回退到全局客户端。 // proxyURL 非空时使用渠道级代理(socks5/socks5h/http/https),否则回退到全局客户端。
func (s *Service) OutboundHTTPClientForChannel(timeout time.Duration, _ *url.URL, proxyURL string) *http.Client { func (s *Service) OutboundHTTPClientForChannel(timeout time.Duration, _ *url.URL, proxyURL string) *http.Client {
return OutboundHTTPClientWithProxy(timeout, proxyURL) return OutboundHTTPClientWithProxy(timeout, proxyURL)
} }

View File

@@ -26,6 +26,11 @@ func ValidateCustomRelayURL(rawURL string) (*url.URL, error) {
return parsed, mapOutboundError(err) 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 { func OutboundHTTPClient(timeout time.Duration) *http.Client {
return outbound.OutboundHTTPClient(timeout) return outbound.OutboundHTTPClient(timeout)
} }

View File

@@ -20,10 +20,10 @@ type ModelChannel struct {
SecretKey string `json:"-"` SecretKey string `json:"-"`
APIFormat string `json:"apiFormat" gorm:"size:24"` APIFormat string `json:"apiFormat" gorm:"size:24"`
ConcurrencyLimit int `json:"concurrencyLimit"` ConcurrencyLimit int `json:"concurrencyLimit"`
// ProxyURL 是渠道级出站代理地址,支持 socks5://、http://、https://。 // ProxyURL 是渠道级出站代理地址,支持 socks5://、socks5h://、http://、https://。
// 留空时使用全局环境变量代理。 // 留空时使用全局环境变量代理。
ProxyURL string `json:"proxyUrl" gorm:"size:512"` ProxyURL string `json:"proxyUrl" gorm:"size:512"`
ModelsJSON string `json:"modelsJson" gorm:"type:text"` ModelsJSON string `json:"modelsJson" gorm:"type:text"`
// RetiredModelsJSON 记录已被一个模型家族吸收的上游 SKU,防止目录拉取时重新创建重复记录。 // RetiredModelsJSON 记录已被一个模型家族吸收的上游 SKU,防止目录拉取时重新创建重复记录。
RetiredModelsJSON string `json:"-" gorm:"type:text"` RetiredModelsJSON string `json:"-" gorm:"type:text"`
HeadersJSON string `json:"-" gorm:"type:text"` HeadersJSON string `json:"-" gorm:"type:text"`

View File

@@ -24,6 +24,7 @@ const (
maxOutboundRedirects = 5 maxOutboundRedirects = 5
maxOutboundHeaderCount = 32 maxOutboundHeaderCount = 32
maxOutboundHeaderBytes = 16 << 10 maxOutboundHeaderBytes = 16 << 10
maxProxyURLBytes = 512
CustomRelayHeadersHeader = "X-Canvas-Upstream-Headers" CustomRelayHeadersHeader = "X-Canvas-Upstream-Headers"
DefaultOutboundUserAgent = "CanvasViewport/1.0 (+https://github.com/ddcat-ai/open-ai-canvas)" 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 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 { func OutboundHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{ return &http.Client{
Transport: outboundTransport, Transport: outboundTransport,
@@ -119,7 +144,7 @@ func OutboundHTTPClientWithProxy(timeout time.Duration, proxyURL string) *http.C
} }
transport := newOutboundTransportWithProxy(proxyURL) transport := newOutboundTransportWithProxy(proxyURL)
if transport == nil { if transport == nil {
return OutboundHTTPClient(timeout) return &http.Client{Transport: rejectingTransport{err: errors.New("渠道代理地址无效")}, Timeout: timeout}
} }
return &http.Client{ return &http.Client{
Transport: transport, 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 { func CustomRelayHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{ return &http.Client{
Transport: customRelayTransport, Transport: customRelayTransport,
@@ -315,8 +346,8 @@ func outboundProxyFromEnvironment(req *http.Request) (*url.URL, error) {
// 本地 DNS 在 TUN/VPN 模式下会返回 fake-ip,若先本地解析再经代 // 本地 DNS 在 TUN/VPN 模式下会返回 fake-ip,若先本地解析再经代
// 理连接会导致远端拿到不可达的假 IP;交给远端解析可避免此问题。 // 理连接会导致远端拿到不可达的假 IP;交给远端解析可避免此问题。
func newOutboundTransportWithProxy(proxyURL string) *http.Transport { func newOutboundTransportWithProxy(proxyURL string) *http.Transport {
parsed, err := url.Parse(proxyURL) parsed, err := ValidateProxyURL(proxyURL)
if err != nil || parsed.Host == "" { if err != nil || parsed == nil {
return nil return nil
} }
scheme := strings.ToLower(parsed.Scheme) scheme := strings.ToLower(parsed.Scheme)

View File

@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"strings"
"testing" "testing"
"time" "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) { func TestNormalizeOutboundHeadersAllowsCustomUserAgent(t *testing.T) {
headers, err := NormalizeOutboundHeaders([]OutboundHeader{{Name: "user-agent", Value: "Custom Gateway/2.0"}, {Name: "X-Gateway-Tenant", Value: "tenant-a"}}) headers, err := NormalizeOutboundHeaders([]OutboundHeader{{Name: "user-agent", Value: "Custom Gateway/2.0"}, {Name: "X-Gateway-Tenant", Value: "tenant-a"}})
if err != nil { if err != nil {

View File

@@ -60,6 +60,7 @@ var (
ValidateCapabilitySpec = app.ValidateCapabilitySpec ValidateCapabilitySpec = app.ValidateCapabilitySpec
ValidateChannelModelPrice = app.ValidateChannelModelPrice ValidateChannelModelPrice = app.ValidateChannelModelPrice
ValidateCustomRelayURL = app.ValidateCustomRelayURL ValidateCustomRelayURL = app.ValidateCustomRelayURL
ValidateProxyURL = app.ValidateProxyURL
ValidateLogicalModelPrice = app.ValidateLogicalModelPrice ValidateLogicalModelPrice = app.ValidateLogicalModelPrice
ValidateOutboundHost = app.ValidateOutboundHost ValidateOutboundHost = app.ValidateOutboundHost
ValidateOutboundURL = app.ValidateOutboundURL ValidateOutboundURL = app.ValidateOutboundURL

View File

@@ -399,7 +399,7 @@ export default function ChannelsPage() {
<Form.Item <Form.Item
name="proxyUrl" name="proxyUrl"
label="代理地址" label="代理地址"
extra="为该渠道单独配置出站代理,支持 socks5://、http://、https://。留空则使用全局环境变量代理。" extra="为该渠道单独配置出站代理,支持 socks5://、socks5h://、http://、https://。留空则使用全局环境变量代理。"
> >
<Input placeholder="例如:socks5://127.0.0.1:1080 或 http://127.0.0.1:7890" /> <Input placeholder="例如:socks5://127.0.0.1:1080 或 http://127.0.0.1:7890" />
</Form.Item> </Form.Item>