feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道

Refs #225

Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
ddCat
2026-08-15 12:45:29 +08:00
parent 8f2232feee
commit 57e1820f41
179 changed files with 49746 additions and 1897 deletions

View File

@@ -729,7 +729,8 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
if encodedQuery := query.Encode(); encodedQuery != "" {
target += "?" + encodedQuery
}
if _, err := service.ValidateOutboundURL(target); err != nil {
validatedTarget, err := svc.ValidateChannelOutboundURL(target, channel.AllowLocalChannel, false)
if err != nil {
_ = svc.RefundBilling(billingOrderID, "系统渠道地址校验失败")
failService(c, err)
return
@@ -764,7 +765,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
}
}
}
upstreamReq, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, target, bytes.NewReader(body))
upstreamReq, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, validatedTarget.String(), bytes.NewReader(body))
if err != nil {
_ = svc.RefundBilling(billingOrderID, "系统渠道请求构造失败")
fail(c, http.StatusBadRequest, err)
@@ -787,7 +788,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
status := model.ApiCallStatusSucceeded
statusCode := 0
errorText := ""
resp, err := service.OutboundHTTPClient(35 * time.Minute).Do(upstreamReq)
resp, err := svc.OutboundHTTPClientForChannel(35*time.Minute, validatedTarget, channel.AllowLocalChannel).Do(upstreamReq)
if err != nil {
status = model.ApiCallStatusFailed
errorText = err.Error()

View File

@@ -18,7 +18,7 @@ import (
const maxCustomRelayErrorResponseBytes int64 = 64 << 10
var customRelayClient = service.CustomRelayHTTPClient
var customRelayClient = service.CustomRelayHTTPClientForChannel
func RegisterCustomRelayRoutes(r *gin.RouterGroup, svc *service.Service) {
r.Any("/ai/custom", func(c *gin.Context) {
@@ -42,12 +42,17 @@ func RegisterCustomRelayRoutes(r *gin.RouterGroup, svc *service.Service) {
return
}
defer release()
proxyCustomRelayRequest(c, policy.Request)
proxyCustomRelayRequestWithCapabilities(c, policy.Request, svc.DesktopLocalChannelsEnabled())
})
}
func proxyCustomRelayRequest(c *gin.Context, policy service.RuntimeRequestPolicy) {
target, err := service.ValidateCustomRelayURL(c.GetHeader("X-Canvas-Upstream-URL"))
proxyCustomRelayRequestWithCapabilities(c, policy, false)
}
func proxyCustomRelayRequestWithCapabilities(c *gin.Context, policy service.RuntimeRequestPolicy, desktopLocalChannelsEnabled bool) {
requestedAllowLocal := strings.TrimSpace(c.GetHeader(service.LocalChannelRequestHeader)) == "1"
target, err := service.ValidateCustomRelayChannelURL(c.GetHeader("X-Canvas-Upstream-URL"), c.GetHeader(service.LocalChannelBaseURLHeader), requestedAllowLocal, desktopLocalChannelsEnabled)
if err != nil {
failService(c, err)
return
@@ -111,7 +116,7 @@ func proxyCustomRelayRequest(c *gin.Context, policy service.RuntimeRequestPolicy
upstreamReq.Header.Set("Authorization", "Bearer "+apiKey)
}
resp, err := customRelayClient(time.Duration(policy.CustomRelayTimeoutMinutes) * time.Minute).Do(upstreamReq)
resp, err := customRelayClient(time.Duration(policy.CustomRelayTimeoutMinutes)*time.Minute, target, requestedAllowLocal, desktopLocalChannelsEnabled).Do(upstreamReq)
if err != nil {
fail(c, http.StatusBadGateway, errors.New("自定义渠道上游连接失败"))
return

View File

@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@@ -47,6 +48,7 @@ func TestCustomRelayForwardsOpenAIRequestWithoutBrowserHeaders(t *testing.T) {
request.Header.Set("Authorization", "Bearer "+apiKey)
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/models")
request.Header.Set("X-Canvas-Upstream-Format", "openai")
markDesktopLocalRelayTestRequest(request, upstream.URL)
request.Header.Set(service.CustomRelayHeadersHeader, base64.StdEncoding.EncodeToString([]byte(`[{"name":"User-Agent","value":"Custom Relay Agent"},{"name":"X-Gateway-Tenant","value":"tenant-a"}]`)))
request.Header.Set("Cookie", "browser=session")
request.Header.Set("Origin", "https://canvas.example.com")
@@ -54,7 +56,7 @@ func TestCustomRelayForwardsOpenAIRequestWithoutBrowserHeaders(t *testing.T) {
context, _ := gin.CreateTestContext(response)
context.Request = request
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
@@ -80,11 +82,12 @@ func TestCustomRelayReturnsVideoContent(t *testing.T) {
request.Header.Set("Authorization", "Bearer test-key")
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/videos/task-1/content")
request.Header.Set("X-Canvas-Upstream-Format", "openai")
markDesktopLocalRelayTestRequest(request, upstream.URL)
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
if response.Code != http.StatusOK || response.Header().Get("Content-Type") != "video/mp4" || response.Body.String() != "video-content" {
t.Fatalf("status = %d, content-type = %q, body = %q", response.Code, response.Header().Get("Content-Type"), response.Body.String())
}
@@ -112,11 +115,12 @@ func TestCustomRelayConvertsGeminiAuthentication(t *testing.T) {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1beta/models/gemini-test:generateContent")
request.Header.Set("X-Canvas-Upstream-Format", "gemini")
markDesktopLocalRelayTestRequest(request, upstream.URL)
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
@@ -140,7 +144,7 @@ func TestCustomRelayStreamsBeforeUpstreamCompletes(t *testing.T) {
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
context, _ := gin.CreateTestContext(w)
context.Request = r
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
}))
defer proxy.Close()
request, _ := http.NewRequest(http.MethodPost, proxy.URL, strings.NewReader(`{"model":"test"}`))
@@ -149,6 +153,7 @@ func TestCustomRelayStreamsBeforeUpstreamCompletes(t *testing.T) {
request.Header.Set("Accept", "text/event-stream")
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/responses")
request.Header.Set("X-Canvas-Upstream-Format", "openai")
markDesktopLocalRelayTestRequest(request, upstream.URL)
client := &http.Client{Timeout: 3 * time.Second}
response, err := client.Do(request)
if err != nil {
@@ -184,7 +189,7 @@ func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
connected := false
previous := customRelayClient
customRelayClient = func(time.Duration) *http.Client {
customRelayClient = func(time.Duration, *url.URL, bool, bool) *http.Client {
connected = true
return http.DefaultClient
}
@@ -196,11 +201,12 @@ func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Canvas-Upstream-URL", "https://127.0.0.1/v1/responses")
request.Header.Set("X-Canvas-Upstream-Format", "openai")
markDesktopLocalRelayTestRequest(request, "https://127.0.0.1")
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
if response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
@@ -209,10 +215,131 @@ func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
}
}
func TestDesktopLocalCustomRelayRequiresServerCapabilityAcrossSupportedPaths(t *testing.T) {
gin.SetMode(gin.TestMode)
requests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if strings.HasSuffix(r.URL.Path, "/content") {
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video-content"))
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"ok":true}`)
}))
defer upstream.Close()
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
tests := []struct {
name string
method string
path string
contentType string
}{
{name: "models", method: http.MethodGet, path: "/v1/models"},
{name: "text", method: http.MethodPost, path: "/v1/responses", contentType: "application/json"},
{name: "image", method: http.MethodPost, path: "/v1/images/generations", contentType: "application/json"},
{name: "video submit", method: http.MethodPost, path: "/v1/videos", contentType: "application/json"},
{name: "audio", method: http.MethodPost, path: "/v1/audio/speech", contentType: "application/json"},
{name: "video poll", method: http.MethodGet, path: "/v1/videos/task-1"},
{name: "video download", method: http.MethodGet, path: "/v1/videos/task-1/content"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
body := io.Reader(nil)
if test.method == http.MethodPost {
body = strings.NewReader(`{}`)
}
request := httptest.NewRequest(test.method, "/api/ai/custom", body)
request.Header.Set("Authorization", "Bearer test-key")
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+test.path)
request.Header.Set("X-Canvas-Upstream-Format", "openai")
request.Header.Set(service.LocalChannelRequestHeader, "1")
request.Header.Set(service.LocalChannelBaseURLHeader, upstream.URL)
if test.contentType != "" {
request.Header.Set("Content-Type", test.contentType)
}
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
before := requests
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), false)
if response.Code == http.StatusOK || requests != before {
t.Fatalf("capability=false status=%d requests=%d->%d", response.Code, before, requests)
}
request = httptest.NewRequest(test.method, "/api/ai/custom", bodyForRelayMethod(test.method))
request.Header.Set("Authorization", "Bearer test-key")
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+test.path)
request.Header.Set("X-Canvas-Upstream-Format", "openai")
request.Header.Set(service.LocalChannelRequestHeader, "1")
request.Header.Set(service.LocalChannelBaseURLHeader, upstream.URL)
if test.contentType != "" {
request.Header.Set("Content-Type", test.contentType)
}
response = httptest.NewRecorder()
context, _ = gin.CreateTestContext(response)
context.Request = request
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
if response.Code != http.StatusOK || requests != before+1 {
t.Fatalf("capability=true status=%d body=%s requests=%d->%d", response.Code, response.Body.String(), before, requests)
}
})
}
}
func TestDesktopLocalCustomRelayDoesNotFollowRedirects(t *testing.T) {
gin.SetMode(gin.TestMode)
reachedTarget := false
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
reachedTarget = true
}))
defer target.Close()
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Location", target.URL+"/v1/models")
w.WriteHeader(http.StatusFound)
}))
defer source.Close()
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
request := httptest.NewRequest(http.MethodGet, "/api/ai/custom", nil)
request.Header.Set("Authorization", "Bearer test-key")
request.Header.Set("X-Canvas-Upstream-URL", source.URL+"/v1/models")
request.Header.Set("X-Canvas-Upstream-Format", "openai")
request.Header.Set(service.LocalChannelRequestHeader, "1")
request.Header.Set(service.LocalChannelBaseURLHeader, source.URL)
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
if response.Code == http.StatusOK {
t.Fatalf("redirect response status=%d body=%s", response.Code, response.Body.String())
}
if reachedTarget {
t.Fatal("desktop local relay redirect target must not be reached")
}
}
func bodyForRelayMethod(method string) io.Reader {
if method == http.MethodPost {
return strings.NewReader(`{}`)
}
return nil
}
func markDesktopLocalRelayTestRequest(request *http.Request, baseURL string) {
request.Header.Set(service.LocalChannelRequestHeader, "1")
request.Header.Set(service.LocalChannelBaseURLHeader, baseURL)
}
func useCustomRelayTestClient(t *testing.T, client *http.Client) {
t.Helper()
previous := customRelayClient
customRelayClient = func(time.Duration) *http.Client { return client }
customRelayClient = func(time.Duration, *url.URL, bool, bool) *http.Client { return client }
t.Cleanup(func() { customRelayClient = previous })
}

View File

@@ -6,21 +6,22 @@ import (
)
type ModelChannel struct {
ID string `json:"id" gorm:"primaryKey;size:36"`
UserID string `json:"userId" gorm:"index;size:36"`
Scope ChannelScope `json:"scope" gorm:"index;size:24"`
Enabled bool `json:"enabled" gorm:"index"`
Name string `json:"name" gorm:"size:80"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"-"`
SecretKey string `json:"-"`
APIFormat string `json:"apiFormat" gorm:"size:24"`
ConcurrencyLimit int `json:"concurrencyLimit"`
ModelsJSON string `json:"modelsJson" gorm:"type:text"`
HeadersJSON string `json:"-" gorm:"type:text"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
ID string `json:"id" gorm:"primaryKey;size:36"`
UserID string `json:"userId" gorm:"index;size:36"`
Scope ChannelScope `json:"scope" gorm:"index;size:24"`
Enabled bool `json:"enabled" gorm:"index"`
Name string `json:"name" gorm:"size:80"`
BaseURL string `json:"baseUrl"`
AllowLocalChannel bool `json:"allowLocalChannel" gorm:"default:false"`
APIKey string `json:"-"`
SecretKey string `json:"-"`
APIFormat string `json:"apiFormat" gorm:"size:24"`
ConcurrencyLimit int `json:"concurrencyLimit"`
ModelsJSON string `json:"modelsJson" gorm:"type:text"`
HeadersJSON string `json:"-" gorm:"type:text"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
type ChannelModel struct {

View File

@@ -85,6 +85,7 @@ type AdminReferenceData struct {
type ChannelRequest struct {
Name string `json:"name"`
BaseURL string `json:"baseUrl"`
AllowLocalChannel *bool `json:"allowLocalChannel"`
APIKey string `json:"apiKey"`
SecretKey string `json:"secretKey"`
ConcurrencyLimit *int `json:"concurrencyLimit"`
@@ -95,22 +96,23 @@ type ChannelRequest struct {
}
type PublicModelChannel struct {
ID string `json:"id"`
UserID string `json:"userId"`
Scope model.ChannelScope `json:"scope"`
Enabled bool `json:"enabled"`
Name string `json:"name"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
APIFormat string `json:"apiFormat"`
ConcurrencyLimit int `json:"concurrencyLimit"`
Models []string `json:"models"`
ModelCosts []PublicChannelModelPrice `json:"modelCosts"`
Headers []OutboundHeader `json:"headers,omitempty"`
HasAPIKey bool `json:"hasApiKey"`
HasSecretKey bool `json:"hasSecretKey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
UserID string `json:"userId"`
Scope model.ChannelScope `json:"scope"`
Enabled bool `json:"enabled"`
Name string `json:"name"`
BaseURL string `json:"baseUrl"`
AllowLocalChannel bool `json:"allowLocalChannel,omitempty"`
APIKey string `json:"apiKey"`
APIFormat string `json:"apiFormat"`
ConcurrencyLimit int `json:"concurrencyLimit"`
Models []string `json:"models"`
ModelCosts []PublicChannelModelPrice `json:"modelCosts"`
Headers []OutboundHeader `json:"headers,omitempty"`
HasAPIKey bool `json:"hasApiKey"`
HasSecretKey bool `json:"hasSecretKey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PublicChannelModelPrice struct {
@@ -502,7 +504,7 @@ func (s *Service) CreateSystemChannel(actor *model.User, req ChannelRequest) (*P
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
channel, err := channelFromRequest(req, model.ModelChannel{ID: newID(), UserID: actor.ID, Scope: model.ChannelScopeSystem, Enabled: true})
channel, err := s.channelFromRequest(req, model.ModelChannel{ID: newID(), UserID: actor.ID, Scope: model.ChannelScopeSystem, Enabled: true})
if err != nil {
return nil, err
}
@@ -535,7 +537,7 @@ func (s *Service) UpdateSystemChannel(actor *model.User, id string, req ChannelR
return nil, err
}
req = mergeChannelRequest(req, *channel)
next, err := channelFromRequest(req, *channel)
next, err := s.channelFromRequest(req, *channel)
if err != nil {
return nil, err
}
@@ -726,6 +728,10 @@ func (s *Service) APICallLogs(actor *model.User, limit int) ([]model.ApiCallLog,
}
func channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.ModelChannel, error) {
return (&Service{}).channelFromRequest(req, channel)
}
func (s *Service) channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.ModelChannel, error) {
name := strings.TrimSpace(req.Name)
baseURL := strings.TrimSpace(req.BaseURL)
if name == "" {
@@ -734,7 +740,14 @@ func channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.M
if baseURL == "" {
return channel, BadAuthRequest("请填写 Base URL")
}
if _, err := ValidateOutboundURL(baseURL); err != nil {
requestedAllowLocal := channel.AllowLocalChannel
if req.AllowLocalChannel != nil {
requestedAllowLocal = *req.AllowLocalChannel
}
if requestedAllowLocal && !s.DesktopLocalChannelsEnabled() {
return channel, BadAuthRequest("当前后端未启用本机渠道")
}
if _, err := s.validateChannelOutboundURL(baseURL, requestedAllowLocal, false); err != nil {
return channel, err
}
models := uniqueNonEmpty(req.Models)
@@ -745,6 +758,7 @@ func channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.M
}
channel.Name = name
channel.BaseURL = strings.TrimRight(baseURL, "/")
channel.AllowLocalChannel = requestedAllowLocal
if req.APIKey != "" {
channel.APIKey = req.APIKey
}
@@ -784,6 +798,10 @@ func mergeChannelRequest(req ChannelRequest, channel model.ModelChannel) Channel
if req.Headers == nil {
req.Headers, _ = ParseOutboundHeadersJSON(channel.HeadersJSON)
}
if req.AllowLocalChannel == nil {
value := channel.AllowLocalChannel
req.AllowLocalChannel = &value
}
return req
}
@@ -827,22 +845,23 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
apiKey = channel.APIKey
}
return PublicModelChannel{
ID: channel.ID,
UserID: channel.UserID,
Scope: channel.Scope,
Enabled: channel.Enabled,
Name: channel.Name,
BaseURL: baseURL,
APIKey: apiKey,
APIFormat: channel.APIFormat,
ConcurrencyLimit: channel.ConcurrencyLimit,
Models: models,
ModelCosts: modelCosts,
Headers: headers,
HasAPIKey: strings.TrimSpace(channel.APIKey) != "",
HasSecretKey: strings.TrimSpace(channel.SecretKey) != "",
CreatedAt: channel.CreatedAt,
UpdatedAt: channel.UpdatedAt,
ID: channel.ID,
UserID: channel.UserID,
Scope: channel.Scope,
Enabled: channel.Enabled,
Name: channel.Name,
BaseURL: baseURL,
AllowLocalChannel: admin && channel.AllowLocalChannel,
APIKey: apiKey,
APIFormat: channel.APIFormat,
ConcurrencyLimit: channel.ConcurrencyLimit,
Models: models,
ModelCosts: modelCosts,
Headers: headers,
HasAPIKey: strings.TrimSpace(channel.APIKey) != "",
HasSecretKey: strings.TrimSpace(channel.SecretKey) != "",
CreatedAt: channel.CreatedAt,
UpdatedAt: channel.UpdatedAt,
}
}

View File

@@ -15,13 +15,9 @@ import (
)
func TestChannelFromRequestStoresConnectionWithoutDefaultProtocol(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer server.Close()
channel, err := channelFromRequest(ChannelRequest{
Name: "混合模型渠道",
BaseURL: server.URL + "/v1",
BaseURL: "https://8.8.8.8/v1",
APIKey: "access-key",
SecretKey: "secret-key",
ConcurrencyLimit: intPtr(6),
@@ -112,7 +108,6 @@ func TestRuntimeConcurrencyUsesEnvironmentFallback(t *testing.T) {
}
func TestFetchAdminChannelModelsReaddsDeletedModel(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`))
@@ -120,8 +115,9 @@ func TestFetchAdminChannelModelsReaddsDeletedModel(t *testing.T) {
defer upstream.Close()
svc, db := newChannelModelTestService(t)
svc.runtimeCapabilities = RuntimeCapabilities{desktopLocalChannels: true}
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}
channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Test", BaseURL: upstream.URL + "/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `[]`}
channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Test", BaseURL: upstream.URL + "/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `[]`, AllowLocalChannel: true}
deleted := model.ChannelModel{ID: "deleted-model", ChannelID: channel.ID, ModelKey: "model-a", DisplayName: "model-a", BillingMode: "fixed_request", PriceVersion: 1}
if err := db.Create(&channel).Error; err != nil {
t.Fatal(err)

View File

@@ -13,10 +13,11 @@ import (
)
type ChannelModelsRequest struct {
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
APIFormat string `json:"apiFormat"`
Headers []OutboundHeader `json:"headers"`
BaseURL string `json:"baseUrl"`
AllowLocalChannel bool `json:"allowLocalChannel"`
APIKey string `json:"apiKey"`
APIFormat string `json:"apiFormat"`
Headers []OutboundHeader `json:"headers"`
}
type channelModelsPayload struct {
@@ -90,11 +91,11 @@ func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.Use
}
target = baseURL + "/models"
}
if _, err := ValidateOutboundURL(target); err != nil {
if _, err := s.validateChannelOutboundURL(target, input.AllowLocalChannel, false); err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
requestContext := withProviderOutboundPolicy(ctx, providerConfig{BaseURL: baseURL, AllowLocalChannel: s.effectiveAllowLocalChannel(input.AllowLocalChannel)})
request, err := http.NewRequestWithContext(requestContext, http.MethodGet, target, nil)
if err != nil {
return nil, BadAuthRequest("模型服务地址无效")
}

View File

@@ -97,7 +97,7 @@ func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User
return nil, err
}
// 使用服务端保存的渠道密钥和请求头访问上游,避免敏感配置再次经过浏览器。
models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, APIKey: channel.APIKey, APIFormat: channel.APIFormat, Headers: headers})
models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, AllowLocalChannel: channel.AllowLocalChannel, APIKey: channel.APIKey, APIFormat: channel.APIFormat, Headers: headers})
if err != nil {
return nil, err
}
@@ -242,6 +242,9 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
if strings.TrimSpace(channel.BaseURL) == "" || strings.TrimSpace(channel.APIKey) == "" {
return nil, BadAuthRequest("请先在渠道中配置 Base URL 和 API Key")
}
if _, err := s.validateChannelOutboundURL(channel.BaseURL, channel.AllowLocalChannel, false); err != nil {
return nil, err
}
headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON)
if err != nil {
return nil, err
@@ -277,6 +280,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
APIFormat: channel.APIFormat,
InterfaceType: string(protocol),
BaseURL: channel.BaseURL,
AllowLocalChannel: s.effectiveAllowLocalChannel(channel.AllowLocalChannel),
APIKey: channel.APIKey,
SecretKey: channel.SecretKey,
Headers: headers,
@@ -305,6 +309,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
Service: s, UserID: actor.ID, ChannelID: channel.ID, Capability: capability,
Operation: "admin_model_test", Model: modelKey, VideoSeconds: videoSecondsValue,
})
testCtx = withProviderOutboundPolicy(testCtx, input.Config)
startedAt := time.Now()
switch capability {
case "text":

View File

@@ -0,0 +1,246 @@
package service
import (
"context"
"errors"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const LocalChannelRequestHeader = "X-Canvas-Allow-Local-Channel"
const LocalChannelBaseURLHeader = "X-Canvas-Upstream-Base-URL"
// RuntimeCapabilities 只来自后端启动配置,HTTP 请求不得修改这些能力。
type RuntimeCapabilities struct {
desktopLocalChannels bool
}
func RuntimeCapabilitiesForDeployment(bindAddr string, desktopLocalChannelsSetting string) RuntimeCapabilities {
if !truthyRuntimeCapability(desktopLocalChannelsSetting) || !isExplicitDesktopLoopbackBind(bindAddr) {
return RuntimeCapabilities{}
}
return RuntimeCapabilities{desktopLocalChannels: true}
}
func truthyRuntimeCapability(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes":
return true
default:
return false
}
}
func isExplicitDesktopLoopbackBind(bindAddr string) bool {
host, port, err := net.SplitHostPort(strings.TrimSpace(bindAddr))
if err != nil || host != "127.0.0.1" {
return false
}
value, err := strconv.Atoi(port)
return err == nil && value >= 1 && value <= 65535
}
func (s *Service) DesktopLocalChannelsEnabled() bool {
return s != nil && s.runtimeCapabilities.desktopLocalChannels
}
func (s *Service) effectiveAllowLocalChannel(requested bool) bool {
return requested && s.DesktopLocalChannelsEnabled()
}
type outboundLookup func(context.Context, string) ([]net.IP, error)
// OutboundPolicy 是独立的桌面 loopback 出口策略。它不读取任何 legacy private-upstream 环境变量。
type OutboundPolicy struct {
lookup outboundLookup
loopbackOnly bool
}
func desktopLoopbackOutboundPolicy(lookup outboundLookup) OutboundPolicy {
if lookup == nil {
lookup = func(ctx context.Context, host string) ([]net.IP, error) {
return net.DefaultResolver.LookupIP(ctx, "ip", host)
}
}
return OutboundPolicy{lookup: lookup, loopbackOnly: true}
}
func validateOutboundURLWithPolicy(rawURL string, policy OutboundPolicy) (*url.URL, error) {
if len(strings.TrimSpace(rawURL)) > 4096 {
return nil, BadAuthRequest("外部服务地址过长")
}
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Hostname() == "" || !parsed.IsAbs() {
return nil, BadAuthRequest("外部服务地址无效")
}
if parsed.Scheme != "https" && parsed.Scheme != "http" {
return nil, BadAuthRequest("外部服务地址只支持 http/https")
}
if parsed.User != nil {
return nil, BadAuthRequest("外部服务地址不允许包含认证信息")
}
if parsed.Fragment != "" {
return nil, BadAuthRequest("外部服务地址不允许包含片段")
}
if policy.loopbackOnly {
if err := validateDesktopLoopbackPort(parsed); err != nil {
return nil, err
}
if _, err := resolveDesktopLoopbackHost(context.Background(), parsed.Hostname(), policy.lookup); err != nil {
return nil, err
}
}
return parsed, nil
}
func validateDesktopLoopbackPort(parsed *url.URL) error {
if parsed == nil || strings.HasSuffix(parsed.Host, ":") {
return BadAuthRequest("外部服务地址端口无效")
}
port := parsed.Port()
if port == "" {
return nil
}
for _, char := range port {
if char < '0' || char > '9' {
return BadAuthRequest("外部服务地址端口无效")
}
}
value, err := strconv.Atoi(port)
if err != nil || value < 1 || value > 65535 {
return BadAuthRequest("外部服务地址端口无效")
}
return nil
}
func resolveDesktopLoopbackHost(ctx context.Context, host string, lookup outboundLookup) ([]net.IP, error) {
if host == "127.0.0.1" {
return []net.IP{net.ParseIP("127.0.0.1")}, nil
}
if !strings.EqualFold(host, "localhost") {
return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址")
}
addresses, err := lookup(ctx, host)
if err != nil {
return nil, BadAuthRequest("外部服务域名解析失败")
}
if len(addresses) == 0 {
return nil, BadAuthRequest("外部服务域名没有可用地址")
}
for _, ip := range addresses {
if ip == nil || !ip.IsLoopback() {
return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址")
}
}
return addresses, nil
}
func outboundHTTPClientWithPolicy(timeout time.Duration, policy OutboundPolicy) *http.Client {
return &http.Client{
Transport: newOutboundTransportWithPolicy(policy),
Timeout: timeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return errors.New("本机渠道不允许重定向")
},
}
}
func newOutboundTransportWithPolicy(policy OutboundPolicy) *http.Transport {
dialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
return &http.Transport{
DialContext: func(ctx context.Context, network string, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
addresses, err := resolveDesktopLoopbackHost(ctx, host, policy.lookup)
if err != nil {
return nil, err
}
var lastErr error
for _, ip := range addresses {
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if dialErr == nil {
return connection, nil
}
lastErr = dialErr
if ctx.Err() != nil {
return nil, ctx.Err()
}
}
return nil, lastErr
},
ForceAttemptHTTP2: true,
MaxIdleConns: 20,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 15 * time.Second,
ExpectContinueTimeout: time.Second,
}
}
func isExactDesktopLoopbackHost(host string) bool {
return host == "127.0.0.1" || strings.EqualFold(host, "localhost")
}
func ValidateCustomRelayChannelURL(rawURL string, configuredBaseURL string, requestedAllowLocal bool, desktopLocalChannelsEnabled bool) (*url.URL, error) {
svc := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: desktopLocalChannelsEnabled}}
target, err := svc.validateChannelOutboundURL(rawURL, requestedAllowLocal, true)
if err != nil || !requestedAllowLocal || !desktopLocalChannelsEnabled || target == nil || !isExactDesktopLoopbackHost(target.Hostname()) {
return target, err
}
base, err := svc.validateChannelOutboundURL(configuredBaseURL, true, true)
if err != nil {
return nil, err
}
if !sameOutboundOrigin(base, target) {
return nil, BadAuthRequest("本机渠道请求必须与配置的 Base URL 同源")
}
return target, nil
}
func sameOutboundOrigin(left *url.URL, right *url.URL) bool {
if left == nil || right == nil {
return false
}
return strings.EqualFold(left.Scheme, right.Scheme) && strings.EqualFold(left.Host, right.Host)
}
func CustomRelayHTTPClientForChannel(timeout time.Duration, target *url.URL, requestedAllowLocal bool, desktopLocalChannelsEnabled bool) *http.Client {
if requestedAllowLocal && desktopLocalChannelsEnabled && target != nil && isExactDesktopLoopbackHost(target.Hostname()) {
return outboundHTTPClientWithPolicy(timeout, desktopLoopbackOutboundPolicy(nil))
}
return CustomRelayHTTPClient(timeout)
}
func (s *Service) ValidateChannelOutboundURL(rawURL string, requestedAllowLocal bool, customRelay bool) (*url.URL, error) {
return s.validateChannelOutboundURL(rawURL, requestedAllowLocal, customRelay)
}
func (s *Service) OutboundHTTPClientForChannel(timeout time.Duration, target *url.URL, requestedAllowLocal bool) *http.Client {
if s.effectiveAllowLocalChannel(requestedAllowLocal) && target != nil && isExactDesktopLoopbackHost(target.Hostname()) {
return outboundHTTPClientWithPolicy(timeout, desktopLoopbackOutboundPolicy(nil))
}
return OutboundHTTPClient(timeout)
}
func (s *Service) validateChannelOutboundURL(rawURL string, requestedAllowLocal bool, customRelay bool) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if requestedAllowLocal {
if !s.effectiveAllowLocalChannel(true) {
return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址")
}
return validateOutboundURLWithPolicy(rawURL, desktopLoopbackOutboundPolicy(nil))
}
if err == nil && isExactDesktopLoopbackHost(parsed.Hostname()) {
return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址")
}
if customRelay {
return ValidateCustomRelayURL(rawURL)
}
return ValidateOutboundURL(rawURL)
}

View File

@@ -0,0 +1,133 @@
package service
import (
"context"
"net"
"net/url"
"strings"
"testing"
"infinite-canvas/backend/internal/model"
)
func TestDesktopLocalChannelModeRejectsPublicAcrossProductionDispatch(t *testing.T) {
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
requested := true
const publicBaseURL = "https://8.8.8.8"
if _, err := open.channelFromRequest(ChannelRequest{Name: "Public", BaseURL: publicBaseURL, AllowLocalChannel: &requested}, model.ModelChannel{Scope: model.ChannelScopeSystem}); err == nil {
t.Fatal("saving allowLocalChannel=true with a public Base URL must fail")
}
if _, err := ValidateCustomRelayChannelURL(publicBaseURL+"/v1/models", publicBaseURL, true, true); err == nil {
t.Fatal("custom relay allowLocalChannel=true must not fall back to the public relay policy")
}
if _, err := open.resolveProviderConfig(providerConfig{BaseURL: publicBaseURL, AllowLocalChannel: true}); err == nil {
t.Fatal("generation provider allowLocalChannel=true must reject a public Base URL before dispatch")
}
}
func TestDesktopLocalChannelModeOwnsOutboundPolicy(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1,localhost,192.168.1.20,169.254.169.254")
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
closed := &Service{}
for _, rawURL := range []string{
"https://8.8.8.8/v1/models",
"http://192.168.1.20:8000/v1/models",
"http://169.254.169.254/latest/meta-data",
"http://127.0.0.2:8000/v1/models",
"http://localhost.:8000/v1/models",
"http://[::1]:8000/v1/models",
} {
if _, err := open.validateChannelOutboundURL(rawURL, true, false); err == nil {
t.Fatalf("allowLocalChannel=true must reject non-exact-loopback URL %q", rawURL)
}
}
for _, rawURL := range []string{
"http://127.0.0.1:8000/v1/models",
"http://localhost:8000/v1/models",
} {
for _, customRelay := range []bool{false, true} {
_, err := open.validateChannelOutboundURL(rawURL, false, customRelay)
if err == nil || !strings.Contains(err.Error(), "不允许访问本机、内网或链路本地地址") {
t.Fatalf("allowLocalChannel=false must reject exact loopback %q with canonical error, customRelay=%v: %v", rawURL, customRelay, err)
}
}
}
for _, customRelay := range []bool{false, true} {
if _, err := open.validateChannelOutboundURL("https://8.8.8.8/v1/models", false, customRelay); err != nil {
t.Fatalf("ordinary public channel must keep the existing outbound policy, customRelay=%v: %v", customRelay, err)
}
}
for _, rawURL := range []string{
"http://127.0.0.1:8000/v1/models",
"https://8.8.8.8/v1/models",
} {
if _, err := closed.validateChannelOutboundURL(rawURL, true, false); err == nil {
t.Fatalf("server capability=false must reject requested local mode for %q", rawURL)
}
}
}
func TestGoURLParserDesktopLoopbackPortBoundary(t *testing.T) {
tests := []struct {
rawURL string
wantErr bool
wantHost string
wantPort string
}{
{rawURL: "http://localhost:", wantHost: "localhost", wantPort: ""},
{rawURL: "http://localhost:0", wantHost: "localhost", wantPort: "0"},
{rawURL: "http://localhost:65536", wantHost: "localhost", wantPort: "65536"},
{rawURL: "http://localhost:abc", wantErr: true},
}
for _, test := range tests {
parsed, err := url.Parse(test.rawURL)
if test.wantErr {
if err == nil {
t.Fatalf("url.Parse(%q) error=nil, want parser error", test.rawURL)
}
continue
}
if err != nil {
t.Fatalf("url.Parse(%q) error=%v", test.rawURL, err)
}
if parsed.Hostname() != test.wantHost || parsed.Port() != test.wantPort {
t.Fatalf("url.Parse(%q) hostname=%q port=%q", test.rawURL, parsed.Hostname(), parsed.Port())
}
}
}
func TestDesktopLoopbackPolicyValidatesPortSyntaxAndRange(t *testing.T) {
policy := desktopLoopbackOutboundPolicy(func(context.Context, string) ([]net.IP, error) {
return []net.IP{net.ParseIP("127.0.0.1")}, nil
})
for _, rawURL := range []string{
"http://localhost",
"http://localhost:1/v1/models",
"http://127.0.0.1:65535/v1/models",
} {
if _, err := validateOutboundURLWithPolicy(rawURL, policy); err != nil {
t.Fatalf("strict local URL %q must be accepted: %v", rawURL, err)
}
}
for _, rawURL := range []string{
"http://localhost:/v1/models",
"http://localhost:0/v1/models",
"http://localhost:65536/v1/models",
"http://localhost:abc/v1/models",
"http://localhost:+1/v1/models",
"http://localhost:-1/v1/models",
"http://localhost:0x50/v1/models",
} {
if _, err := validateOutboundURLWithPolicy(rawURL, policy); err == nil {
t.Fatalf("strict local URL %q must reject invalid port syntax/range", rawURL)
}
}
}

View File

@@ -0,0 +1,99 @@
package service
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"infinite-canvas/backend/internal/model"
)
func TestLocalExecutionPathsApplyLoopbackPolicyBeyondConfigResolution(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
requests := map[string]int{}
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests[r.URL.Path]++
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/contents/generations/tasks/cancel-task":
w.WriteHeader(http.StatusNoContent)
case r.Method == http.MethodGet && r.URL.Path == "/v1/video/generations/recovery-task":
_, _ = w.Write([]byte(`{"data":{"status":"IN_PROGRESS"}}`))
case r.Method == http.MethodPost && r.URL.Path == "/v1/chat/completions":
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"storyboard-ok"}}]}`))
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
closed := &Service{}
tests := []struct {
name string
config providerConfig
path string
runRequest func(context.Context, providerConfig) error
}{
{
name: "cancel",
config: providerConfig{BaseURL: upstream.URL + "/api/v3", APIKey: "key", Model: "video-model", InterfaceType: string(model.ChannelInterfaceVolcengineArkVideo), AllowLocalChannel: true},
path: "/api/v3/contents/generations/tasks/cancel-task",
runRequest: func(ctx context.Context, config providerConfig) error {
return cancelProviderTask(ctx, config, "cancel-task")
},
},
{
name: "recovery",
config: providerConfig{BaseURL: upstream.URL + "/v1", APIKey: "key", Model: "video-model", InterfaceType: string(model.ChannelInterfaceNewAPIChannel2), AllowLocalChannel: true},
path: "/v1/video/generations/recovery-task",
runRequest: func(ctx context.Context, config providerConfig) error {
_, _, err := queryNewAPIChannel2VideoTask(ctx, canvasGenerationInput{Mode: "video", Config: config}, "recovery-task")
return err
},
},
{
name: "storyboard",
config: providerConfig{BaseURL: upstream.URL + "/v1", APIKey: "key", Model: "text-model", InterfaceType: string(model.ChannelInterfaceChatCompletion), AllowLocalChannel: true},
path: "/v1/chat/completions",
runRequest: func(ctx context.Context, config providerConfig) error {
result, err := runTextTask(ctx, canvasGenerationInput{Mode: "text", Prompt: "storyboard", Config: config})
if err != nil {
return err
}
if result["text"] != "storyboard-ok" {
return fmt.Errorf("storyboard text = %v", result["text"])
}
return nil
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := closed.resolveProviderConfig(test.config); err == nil {
t.Fatal("capability=false must reject local execution before any outbound request")
}
before := requests[test.path]
resolved, err := open.resolveProviderConfig(test.config)
if err != nil {
t.Fatalf("resolveProviderConfig() error = %v", err)
}
if err := test.runRequest(context.Background(), resolved); err == nil {
t.Fatal("local request without loopback policy context must still fail SSRF validation")
}
if requests[test.path] != before {
t.Fatal("request without loopback policy must not reach upstream")
}
ctx := withProviderOutboundPolicy(context.Background(), resolved)
if err := test.runRequest(ctx, resolved); err != nil {
t.Fatalf("local request with loopback policy error = %v", err)
}
if requests[test.path] != before+1 {
t.Fatalf("upstream requests = %d, want %d", requests[test.path], before+1)
}
})
}
}

View File

@@ -0,0 +1,203 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestModelChannelAllowLocalDefaultsFalseAfterAutoMigrate(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.ModelChannel{}); err != nil {
t.Fatal(err)
}
channel := model.ModelChannel{ID: "legacy-channel", Name: "Legacy", BaseURL: "https://example.com", Scope: model.ChannelScopeSystem, Enabled: true}
if err := db.Omit("AllowLocalChannel").Create(&channel).Error; err != nil {
t.Fatal(err)
}
var stored model.ModelChannel
if err := db.First(&stored, "id = ?", channel.ID).Error; err != nil {
t.Fatal(err)
}
if stored.AllowLocalChannel {
t.Fatal("legacy row must migrate with allowLocalChannel=false")
}
}
func TestSystemChannelAllowLocalSaveRequiresCapabilityAndPatchPreservesOmission(t *testing.T) {
requested := true
closed := &Service{}
_, err := closed.channelFromRequest(ChannelRequest{Name: "Local", BaseURL: "http://127.0.0.1:8000", AllowLocalChannel: &requested}, model.ModelChannel{Scope: model.ChannelScopeSystem})
if err == nil {
t.Fatal("server capability=false must reject saving allowLocalChannel=true")
}
if _, err := closed.channelFromRequest(ChannelRequest{Name: "Forged", BaseURL: "https://8.8.8.8", AllowLocalChannel: &requested}, model.ModelChannel{Scope: model.ChannelScopeSystem}); err == nil {
t.Fatal("server capability=false must reject persisting a forged allowLocalChannel=true flag even for a public Base URL")
}
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
channel, err := open.channelFromRequest(ChannelRequest{Name: "Local", BaseURL: "http://127.0.0.1:8000", AllowLocalChannel: &requested}, model.ModelChannel{Scope: model.ChannelScopeSystem})
if err != nil {
t.Fatalf("channelFromRequest() error = %v", err)
}
if !channel.AllowLocalChannel || channel.BaseURL != "http://127.0.0.1:8000" {
t.Fatalf("stored channel = %#v", channel)
}
merged := mergeChannelRequest(ChannelRequest{Enabled: boolPtr(false)}, channel)
if merged.AllowLocalChannel == nil || !*merged.AllowLocalChannel {
t.Fatal("PATCH omitting allowLocalChannel must preserve stored true")
}
}
func TestPublicSystemChannelHidesLocalAddressAndLocalFlagFromOrdinaryUsers(t *testing.T) {
channel := model.ModelChannel{
ID: "system-local", Scope: model.ChannelScopeSystem, Enabled: true, Name: "Desktop Local",
BaseURL: "http://127.0.0.1:8000", APIKey: "secret", AllowLocalChannel: true,
}
admin := publicChannel(channel, true, nil)
if admin.BaseURL != channel.BaseURL || !admin.AllowLocalChannel {
t.Fatalf("admin channel = %#v", admin)
}
user := publicChannel(channel, false, nil)
if user.BaseURL != "/api/ai/system/system-local" || user.APIKey != "system" || user.AllowLocalChannel {
t.Fatalf("ordinary user channel leaked local configuration: %#v", user)
}
}
func TestCustomModelCatalogConsumesAllowLocalChannelAndServerCapability(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"local-model"}]}`))
}))
defer upstream.Close()
actor := &model.User{ID: "user-1"}
input := ChannelModelsRequest{BaseURL: upstream.URL, APIKey: "test-key", APIFormat: "openai", AllowLocalChannel: true}
closed := &Service{}
if _, err := closed.FetchChannelModelCatalog(context.Background(), actor, input); err == nil {
t.Fatal("model catalog must reject forged allowLocalChannel=true when server capability=false")
}
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
catalog, err := open.FetchChannelModelCatalog(context.Background(), actor, input)
if err != nil {
t.Fatalf("FetchChannelModelCatalog() error = %v", err)
}
if len(catalog) != 1 || catalog[0].ID != "local-model" {
t.Fatalf("catalog = %#v", catalog)
}
}
func TestSystemWorkerRestartReevaluatesStoredLocalFlagAgainstServerCapability(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}); err != nil {
t.Fatal(err)
}
channel := model.ModelChannel{
ID: "channel-local", UserID: "admin", Scope: model.ChannelScopeSystem, Enabled: true, Name: "Local",
BaseURL: "http://127.0.0.1:8000", APIKey: "key", APIFormat: "openai", ModelsJSON: `["text-model"]`, AllowLocalChannel: true,
}
channelModel := model.ChannelModel{ID: "model-local", ChannelID: channel.ID, ModelKey: "text-model", Capability: "text", Protocol: model.ChannelInterfaceChatCompletion, BillingMode: "fixed_request", Enabled: true, PriceConfigured: true}
if err := db.Create(&channel).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&channelModel).Error; err != nil {
t.Fatal(err)
}
repo := repository.New(db)
open := &Service{repo: repo, runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
resolved, err := open.resolveProviderConfig(providerConfig{ChannelID: channel.ID, Model: "text-model"})
if err != nil {
t.Fatalf("desktop resolveProviderConfig() error = %v", err)
}
if !resolved.AllowLocalChannel {
t.Fatal("desktop worker must retain the effective local policy in resolved provider config")
}
encoded, err := json.Marshal(resolved)
if err != nil {
t.Fatal(err)
}
var recovered providerConfig
if err := json.Unmarshal(encoded, &recovered); err != nil {
t.Fatal(err)
}
closedAfterRestart := &Service{repo: repo, runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: false}}
if _, err := closedAfterRestart.resolveProviderConfig(recovered); err == nil {
t.Fatal("restart with server capability=false must reject a DB/task payload that still carries allowLocalChannel=true")
}
}
func TestCustomTaskInputRecoveryReevaluatesLocalFlagAgainstServerCapability(t *testing.T) {
input, err := normalizeTaskInput(map[string]any{
"mode": "text",
"config": providerConfig{BaseURL: "http://127.0.0.1:8000", APIKey: "key", Model: "text-model", AllowLocalChannel: true},
})
if err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
var recovered canvasGenerationInput
if err := json.Unmarshal(encoded, &recovered); err != nil {
t.Fatal(err)
}
if !recovered.Config.AllowLocalChannel {
t.Fatal("task InputJSON recovery lost allowLocalChannel")
}
closed := &Service{}
if _, err := closed.resolveProviderConfig(recovered.Config); err == nil {
t.Fatal("recovered custom task must fail closed when server capability=false")
}
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
resolved, err := open.resolveProviderConfig(recovered.Config)
if err != nil || !resolved.AllowLocalChannel {
t.Fatalf("desktop recovered config = %#v, error = %v", resolved, err)
}
}
func TestSystemChannelOutboundClientUsesStoredLocalFlagAndCapability(t *testing.T) {
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer source.Close()
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
closed := &Service{}
if _, err := closed.ValidateChannelOutboundURL(source.URL, true, false); err == nil {
t.Fatal("stored allowLocalChannel=true must remain closed when server capability=false")
}
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
target, err := open.ValidateChannelOutboundURL(source.URL, true, false)
if err != nil {
t.Fatalf("ValidateChannelOutboundURL() error = %v", err)
}
request, _ := http.NewRequest(http.MethodGet, target.String(), nil)
response, err := open.OutboundHTTPClientForChannel(time.Second, target, true).Do(request)
if err != nil {
t.Fatalf("OutboundHTTPClientForChannel() error = %v", err)
}
_ = response.Body.Close()
}
func boolPtr(value bool) *bool { return &value }

View File

@@ -0,0 +1,226 @@
package service
import (
"context"
"net"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
"time"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm/schema"
)
func TestRuntimeCapabilitiesDoNotExposeWritableDesktopFlag(t *testing.T) {
typeInfo := reflect.TypeOf(RuntimeCapabilities{})
if field, ok := typeInfo.FieldByName("DesktopLocalChannels"); ok && field.IsExported() {
t.Fatal("RuntimeCapabilities must not expose a writable DesktopLocalChannels field outside the service package")
}
}
func TestRuntimeCapabilitiesRequireExplicitDesktopFlagAndLoopbackBind(t *testing.T) {
tests := []struct {
name string
bind string
setting string
want bool
}{
{name: "default disabled", bind: "127.0.0.1:8080", setting: "", want: false},
{name: "explicit loopback desktop", bind: "127.0.0.1:8080", setting: "true", want: true},
{name: "wildcard bind stays closed", bind: ":8080", setting: "true", want: false},
{name: "all interfaces stays closed", bind: "0.0.0.0:8080", setting: "true", want: false},
{name: "lan bind stays closed", bind: "192.168.1.5:8080", setting: "true", want: false},
{name: "hostname bind is not a trusted bind identity", bind: "localhost:8080", setting: "true", want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
capabilities := RuntimeCapabilitiesForDeployment(test.bind, test.setting)
if capabilities.desktopLocalChannels != test.want {
t.Fatalf("desktopLocalChannels = %v, want %v", capabilities.desktopLocalChannels, test.want)
}
})
}
}
func TestModelChannelLocalFlagHasFalseMigrationDefault(t *testing.T) {
parsed, err := schema.Parse(&model.ModelChannel{}, &sync.Map{}, schema.NamingStrategy{})
if err != nil {
t.Fatal(err)
}
field := parsed.LookUpField("AllowLocalChannel")
if field == nil || !field.HasDefaultValue || strings.ToLower(strings.TrimSpace(field.DefaultValue)) != "false" {
t.Fatalf("AllowLocalChannel migration field = %#v", field)
}
if (model.ModelChannel{}).AllowLocalChannel {
t.Fatal("zero/legacy ModelChannel must default allowLocalChannel=false")
}
}
func TestRuntimeFeatureAvailabilityExposesDesktopCapabilityReadOnly(t *testing.T) {
svc := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
features := svc.withRuntimeCapabilities(publicFeatureAvailability(nil, defaultFeatureAvailability()))
if !features.DesktopLocalChannelsEnabled {
t.Fatal("public feature payload must expose the server-held desktop capability")
}
if writable := defaultFeatureAvailability(); writable.ShortDramaEnabled != features.ShortDramaEnabled || writable.TaskCenterEnabled != features.TaskCenterEnabled || writable.CreditsEnabled != features.CreditsEnabled {
t.Fatal("runtime capability must not alter writable feature availability")
}
}
func TestDesktopLocalChannelRequiresRequestedFlagAndServerCapability(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1,localhost")
closed := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: false}}
if _, err := closed.validateChannelOutboundURL("http://127.0.0.1:8000/v1/models", true, false); err == nil {
t.Fatal("server capability=false must reject a forged requested allowLocalChannel=true even when legacy private overrides are enabled")
}
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
desktop := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
if _, err := desktop.validateChannelOutboundURL("http://127.0.0.1:8000/v1/models", false, false); err == nil {
t.Fatal("requested allowLocalChannel=false must reject loopback")
}
for _, rawURL := range []string{
"http://127.0.0.1:8000/v1/models",
"http://localhost:8000/v1/models",
} {
if _, err := desktop.validateChannelOutboundURL(rawURL, true, false); err != nil {
t.Fatalf("validateChannelOutboundURL(%q) error = %v", rawURL, err)
}
}
}
func TestDesktopLoopbackPolicyRejectsNonExactHostForms(t *testing.T) {
policy := desktopLoopbackOutboundPolicy(nil)
for _, rawURL := range []string{
"http://[::1]:8000/v1/models",
"http://[::ffff:127.0.0.1]:8000/v1/models",
"http://0.0.0.0:8000/v1/models",
"http://127.0.0.2:8000/v1/models",
"http://127.1:8000/v1/models",
"http://2130706433:8000/v1/models",
"http://0x7f000001:8000/v1/models",
"http://0177.0.0.1:8000/v1/models",
"http://localhost.:8000/v1/models",
"http://x.localhost:8000/v1/models",
"http://127.0.0.1.evil.test:8000/v1/models",
"http://user:pass@127.0.0.1:8000/v1/models",
"http://127.0.0.1:8000/v1/models#fragment",
"http://169.254.169.254/latest/meta-data",
"http://10.0.0.1:8000/v1/models",
"http://192.168.1.20:8000/v1/models",
} {
if _, err := validateOutboundURLWithPolicy(rawURL, policy); err == nil {
t.Fatalf("validateOutboundURLWithPolicy(%q) should fail", rawURL)
}
}
}
func TestDesktopLocalRelayRequiresConfiguredBaseURLOrigin(t *testing.T) {
if _, err := ValidateCustomRelayChannelURL("http://127.0.0.1:8000/v1/models", "http://127.0.0.1:8000", true, true); err != nil {
t.Fatalf("same-origin local relay error = %v", err)
}
for _, baseURL := range []string{"", "http://127.0.0.1:8001", "http://localhost:8000"} {
if _, err := ValidateCustomRelayChannelURL("http://127.0.0.1:8000/v1/models", baseURL, true, true); err == nil {
t.Fatalf("configured Base URL %q must not authorize a different local origin", baseURL)
}
}
}
func TestDesktopLoopbackPolicyRejectsMixedLocalhostResolution(t *testing.T) {
policy := desktopLoopbackOutboundPolicy(func(context.Context, string) ([]net.IP, error) {
return []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("10.0.0.8")}, nil
})
if _, err := validateOutboundURLWithPolicy("http://localhost:8000/v1/models", policy); err == nil {
t.Fatal("localhost must fail when any resolved address is not loopback")
}
}
func TestDesktopLoopbackPolicyFallsBackAcrossValidatedLocalhostAddresses(t *testing.T) {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
server.Listener = listener
server.Start()
defer server.Close()
_, port, err := net.SplitHostPort(listener.Addr().String())
if err != nil {
t.Fatal(err)
}
policy := desktopLoopbackOutboundPolicy(func(context.Context, string) ([]net.IP, error) {
return []net.IP{net.ParseIP("::1"), net.ParseIP("127.0.0.1")}, nil
})
request, _ := http.NewRequest(http.MethodGet, "http://localhost:"+port+"/health", nil)
response, err := outboundHTTPClientWithPolicy(time.Second, policy).Do(request)
if err != nil {
t.Fatalf("dual-stack localhost request error = %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusNoContent)
}
}
func TestDesktopLoopbackPolicyRechecksDNSAtDialTime(t *testing.T) {
calls := 0
policy := desktopLoopbackOutboundPolicy(func(context.Context, string) ([]net.IP, error) {
calls++
if calls == 1 {
return []net.IP{net.ParseIP("127.0.0.1")}, nil
}
return []net.IP{net.ParseIP("10.0.0.9")}, nil
})
const rawURL = "http://localhost:18080/v1/models"
if _, err := validateOutboundURLWithPolicy(rawURL, policy); err != nil {
t.Fatalf("initial validation error = %v", err)
}
request, _ := http.NewRequest(http.MethodGet, rawURL, nil)
_, err := outboundHTTPClientWithPolicy(500*time.Millisecond, policy).Do(request)
if err == nil {
t.Fatal("dial-time DNS rebinding to a non-loopback address must fail")
}
if calls < 2 {
t.Fatalf("resolver calls = %d, want at least 2", calls)
}
}
func TestDesktopLoopbackPolicyNeverFollowsRedirects(t *testing.T) {
reachedTarget := false
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
reachedTarget = true
}))
defer target.Close()
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Location", target.URL+"/secret")
w.WriteHeader(http.StatusFound)
}))
defer source.Close()
policy := desktopLoopbackOutboundPolicy(nil)
request, _ := http.NewRequest(http.MethodGet, source.URL+"/start", nil)
response, err := outboundHTTPClientWithPolicy(time.Second, policy).Do(request)
if err == nil {
if response != nil {
_ = response.Body.Close()
}
t.Fatal("desktop loopback client must reject redirects")
}
if reachedTarget {
t.Fatal("redirect target must not receive the request")
}
if !strings.Contains(err.Error(), "重定向") {
t.Fatalf("redirect error = %q", err.Error())
}
}

View File

@@ -27,9 +27,10 @@ type FeatureAvailability struct {
type PublicFeatureAvailability struct {
FeatureAvailability
Configured bool `json:"configured"`
UpdatedBy string `json:"updatedBy,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
DesktopLocalChannelsEnabled bool `json:"desktopLocalChannelsEnabled"`
Configured bool `json:"configured"`
UpdatedBy string `json:"updatedBy,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
}
func defaultFeatureAvailability() FeatureAvailability {
@@ -42,7 +43,7 @@ func (s *Service) FeatureAvailability() (*PublicFeatureAvailability, error) {
if err != nil {
return nil, err
}
return publicFeatureAvailability(setting, value), nil
return s.withRuntimeCapabilities(publicFeatureAvailability(setting, value)), nil
}
func (s *Service) AdminFeatureAvailability(actor *model.User) (*PublicFeatureAvailability, error) {
@@ -74,7 +75,7 @@ func (s *Service) UpdateFeatureAvailability(actor *model.User, value FeatureAvai
if err := s.appendAdminAudit(actor, "feature_availability.update", "system_setting", featureAvailabilitySettingKey, "更新功能开放配置", map[string]any{"before": before, "after": value}); err != nil {
return nil, err
}
return publicFeatureAvailability(&setting, value), nil
return s.withRuntimeCapabilities(publicFeatureAvailability(&setting, value)), nil
}
func (s *Service) FeatureEnabled(feature string) (bool, error) {
@@ -129,6 +130,13 @@ func (s *Service) readFeatureAvailability() (*model.SystemSetting, FeatureAvaila
return setting, value, nil
}
func (s *Service) withRuntimeCapabilities(result *PublicFeatureAvailability) *PublicFeatureAvailability {
if result != nil {
result.DesktopLocalChannelsEnabled = s.DesktopLocalChannelsEnabled()
}
return result
}
func publicFeatureAvailability(setting *model.SystemSetting, value FeatureAvailability) *PublicFeatureAvailability {
result := &PublicFeatureAvailability{FeatureAvailability: value, Configured: setting != nil}
if setting != nil {

View File

@@ -44,6 +44,7 @@ type providerConfig struct {
APIFormat string `json:"apiFormat"`
InterfaceType string `json:"interfaceType"`
BaseURL string `json:"baseUrl"`
AllowLocalChannel bool `json:"allowLocalChannel"`
APIKey string `json:"apiKey"`
SecretKey string `json:"secretKey"`
Headers []OutboundHeader `json:"headers"`
@@ -100,6 +101,12 @@ type providerHTTPError struct {
}
type providerAnalyticsKey struct{}
type providerOutboundPolicyKey struct{}
type providerOutboundPolicyContext struct {
scheme string
host string
}
type providerAnalyticsContext struct {
Service *Service
@@ -183,6 +190,7 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string
return nil, err
}
input.Config = config
ctx = withProviderOutboundPolicy(ctx, input.Config)
if input.Mode == "image" && input.Metadata != nil {
if err := s.applyGenerationStyleProfile(userID, taskProjectID, &input); err != nil {
return nil, err
@@ -567,9 +575,10 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig,
channelID = systemChannelIDFromBaseURL(config.BaseURL)
}
if channelID == "" {
if _, err := ValidateOutboundURL(config.BaseURL); err != nil {
if _, err := s.validateChannelOutboundURL(config.BaseURL, config.AllowLocalChannel, false); err != nil {
return providerConfig{}, err
}
config.AllowLocalChannel = s.effectiveAllowLocalChannel(config.AllowLocalChannel)
return config, nil
}
channel, err := s.SystemChannel(channelID)
@@ -587,6 +596,9 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig,
if !stringInSlice(modelName, channelModelNames(*channel)) {
return providerConfig{}, errors.New("当前系统渠道未授权该模型")
}
if _, err := s.validateChannelOutboundURL(channel.BaseURL, channel.AllowLocalChannel, false); err != nil {
return providerConfig{}, err
}
config.ChannelID = channel.ID
config.APIFormat = channel.APIFormat
channelModel, modelErr := s.repo.ChannelModelByKey(channel.ID, modelName)
@@ -601,6 +613,7 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig,
config.APIFormat = "openai"
}
config.BaseURL = channel.BaseURL
config.AllowLocalChannel = s.effectiveAllowLocalChannel(channel.AllowLocalChannel)
config.APIKey = channel.APIKey
config.SecretKey = channel.SecretKey
config.Headers, err = ParseOutboundHeadersJSON(channel.HeadersJSON)
@@ -2664,6 +2677,28 @@ func doJSON(req *http.Request, target interface{}) error {
return nil
}
func withProviderOutboundPolicy(ctx context.Context, config providerConfig) context.Context {
if !config.AllowLocalChannel {
return ctx
}
parsed, err := url.Parse(strings.TrimSpace(config.BaseURL))
if err != nil || !isExactDesktopLoopbackHost(parsed.Hostname()) {
return ctx
}
return context.WithValue(ctx, providerOutboundPolicyKey{}, providerOutboundPolicyContext{scheme: strings.ToLower(parsed.Scheme), host: strings.ToLower(parsed.Host)})
}
func providerLoopbackPolicyForRequest(req *http.Request) (OutboundPolicy, bool) {
policyContext, ok := req.Context().Value(providerOutboundPolicyKey{}).(providerOutboundPolicyContext)
if !ok || policyContext.scheme == "" || policyContext.host == "" {
return OutboundPolicy{}, false
}
if strings.ToLower(req.URL.Scheme) != policyContext.scheme || strings.ToLower(req.URL.Host) != policyContext.host {
return OutboundPolicy{}, false
}
return desktopLoopbackOutboundPolicy(nil), true
}
func doBinary(req *http.Request) ([]byte, string, error) {
startedAt := time.Now()
requestTimeout := providerHTTPTimeout
@@ -2707,12 +2742,21 @@ func doBinary(req *http.Request) ([]byte, string, error) {
}
defer release()
}
if _, err := ValidateOutboundURL(req.URL.String()); err != nil {
policy, loopback := providerLoopbackPolicyForRequest(req)
if loopback {
if _, err := validateOutboundURLWithPolicy(req.URL.String(), policy); err != nil {
recordProviderRequest(req, startedAt, 0, nil, err)
return nil, "", err
}
} else if _, err := ValidateOutboundURL(req.URL.String()); err != nil {
recordProviderRequest(req, startedAt, 0, nil, err)
return nil, "", err
}
ApplyDefaultOutboundHeaders(req)
client := OutboundHTTPClient(requestTimeout)
if loopback {
client = outboundHTTPClientWithPolicy(requestTimeout, policy)
}
resp, err := client.Do(req)
if err != nil {
if runtimeService != nil {

View File

@@ -80,6 +80,7 @@ func (s *Service) requestProviderCancellation(ctx context.Context, task *model.T
requestTask := *task
requestTask.InputJSON = mustJSON(input)
requestCtx = withProviderRequestKind(withProviderAnalytics(requestCtx, s, requestTask), "cancel")
requestCtx = withProviderOutboundPolicy(requestCtx, input.Config)
if err := cancelProviderTask(requestCtx, input.Config, task.ProviderRequestID); err != nil {
return s.markProviderCancellationUncertain(task, "上游取消请求结果不明确,费用待核对:"+safeProviderLogError(err))
}
@@ -98,6 +99,7 @@ func (s *Service) reconcileProviderCancellation(ctx context.Context, task *model
queryTask := *task
queryTask.InputJSON = mustJSON(input)
queryCtx := withProviderRequestKind(withProviderAnalytics(ctx, s, queryTask), "cancel-query")
queryCtx = withProviderOutboundPolicy(queryCtx, input.Config)
outcome, providerStatus, err := queryProviderCancellation(queryCtx, input.Config, task.ProviderRequestID)
if err != nil {
if task.ProviderCancelAttempts >= providerCancellationMaxAttempts-1 {

View File

@@ -124,6 +124,7 @@ func (s *Service) queryFailedVideoTask(ctx context.Context, task *model.Task, cl
defer func() { _ = s.repo.ReleaseTaskProviderRecovery(task.ID, owner) }()
queryCtx := withProviderAnalytics(ctx, s, *task)
queryCtx = withProviderOutboundPolicy(queryCtx, input.Config)
result, providerStatus, err := queryNewAPIChannel2VideoTask(queryCtx, input, providerRequestID)
if err != nil {
_ = s.log(task.UserID, task.ID, "error", "人工查询上游视频任务失败", err.Error())

View File

@@ -1240,13 +1240,10 @@ func TestValidateGenerationInterfaceRejectsMismatchedType(t *testing.T) {
}
func TestProcessTaskValidatesInterfaceBeforeHydratingMedia(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer server.Close()
input := canvasGenerationInput{
Mode: "video",
Prompt: "make it move",
Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "key", Model: "text-model", InterfaceType: "chat-completion"},
Config: providerConfig{BaseURL: "https://8.8.8.8/v1", APIKey: "key", Model: "text-model", InterfaceType: "chat-completion"},
ReferenceImages: []providerMedia{{StorageKey: "resource:missing"}},
}
raw, _ := json.Marshal(input)

View File

@@ -22,19 +22,20 @@ import (
)
type Service struct {
repo *repository.Repository
dataDir string
cancelMu sync.Mutex
registrationMu sync.Mutex
emailCodeMu sync.Mutex
redeemBatchMu sync.Mutex
storageMu sync.Mutex
characterTaskMu sync.Mutex
activeCancels map[string]context.CancelFunc
pendingStorage map[string]int64
coordinator *runtimeCoordinator
runtimeErr error
workerID string
repo *repository.Repository
dataDir string
runtimeCapabilities RuntimeCapabilities
cancelMu sync.Mutex
registrationMu sync.Mutex
emailCodeMu sync.Mutex
redeemBatchMu sync.Mutex
storageMu sync.Mutex
characterTaskMu sync.Mutex
activeCancels map[string]context.CancelFunc
pendingStorage map[string]int64
coordinator *runtimeCoordinator
runtimeErr error
workerID string
}
const taskWorkerConcurrency = 3
@@ -190,8 +191,12 @@ type agentStoryboardShot struct {
}
func New(repo *repository.Repository, dataDir string) *Service {
return NewWithRuntimeCapabilities(repo, dataDir, RuntimeCapabilities{})
}
func NewWithRuntimeCapabilities(repo *repository.Repository, dataDir string, capabilities RuntimeCapabilities) *Service {
coordinator, err := newRuntimeCoordinator(repo.Dialect())
return &Service{repo: repo, dataDir: dataDir, activeCancels: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, workerID: newID()}
return &Service{repo: repo, dataDir: dataDir, runtimeCapabilities: capabilities, activeCancels: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, workerID: newID()}
}
func (s *Service) StartWorker() {
@@ -1131,6 +1136,7 @@ func (s *Service) processAgentStoryboardTask(ctx context.Context, task model.Tas
if err != nil {
return nil, nil, err
}
ctx = withProviderOutboundPolicy(ctx, config)
plannerPrompt, err := s.buildAgentStoryboardPlannerPrompt(task.UserID, task.Prompt, input.Requirements, assets, input.ProjectStyle, input.Characters, 0, 0)
if err != nil {
return nil, nil, err
@@ -1174,6 +1180,7 @@ func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task
if err != nil {
return nil, nil, err
}
ctx = withProviderOutboundPolicy(ctx, config)
plannerPrompt, err := s.buildAgentStoryboardPlannerPrompt(task.UserID, task.Prompt, input.Requirements, assets, input.ProjectStyle, input.Characters, input.ShotDuration, input.ShotCount)
if err != nil {
return nil, nil, err