diff --git a/admin/config.example.yaml b/admin/config.example.yaml index a1c8ee3..d14030f 100644 --- a/admin/config.example.yaml +++ b/admin/config.example.yaml @@ -50,7 +50,7 @@ ai: # 留空时 Admin 正常启动,但不能保存密钥或测试 AI 连接。 secrets_path: "" - # 默认只允许公网 HTTPS 服务。确需访问私有模型端点时,必须由部署人员 + # 公网 AI 服务支持 HTTP 和 HTTPS;HTTP 不提供传输加密。确需访问私有模型端点时,必须由部署人员 # 在这里或 CMAUTOBUY_AI_ALLOWED_HOSTS 中按主机名显式允许,网页不能修改。 allowed_hosts: [] diff --git a/admin/service/ai_config.go b/admin/service/ai_config.go index 743d426..d7e3c24 100644 --- a/admin/service/ai_config.go +++ b/admin/service/ai_config.go @@ -328,8 +328,12 @@ func ParseConfidenceThresholdBPS(raw string) (int, error) { func (p AIEndpointPolicy) ValidateSyntax(raw string) (string, error) { parsed, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || parsed.Scheme != "https" || parsed.Host == "" { - return "", &validationError{field: "base_url", message: "Base URL 必须是完整的 HTTPS 地址"} + if err != nil || parsed.Host == "" { + return "", &validationError{field: "base_url", message: "Base URL 必须是完整的 HTTP 或 HTTPS 地址"} + } + parsed.Scheme = strings.ToLower(parsed.Scheme) + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", &validationError{field: "base_url", message: "Base URL 只支持 HTTP 或 HTTPS"} } if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { return "", &validationError{field: "base_url", message: "Base URL 不能包含账号密码、查询参数或片段"} diff --git a/admin/service/ai_config_test.go b/admin/service/ai_config_test.go index 8eb8b2e..0662a2f 100644 --- a/admin/service/ai_config_test.go +++ b/admin/service/ai_config_test.go @@ -4,6 +4,7 @@ import ( "context" "io" "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -57,23 +58,56 @@ func TestSaveAIProviderConfig_新增后可查询并与审计同事务(t *testing } } -func TestAIEndpointPolicy_阻止凭据和内网地址(t *testing.T) { +func TestAIEndpointPolicy_兼容HTTP并阻止凭据和内网地址(t *testing.T) { policy := NewAIEndpointPolicy(nil) for _, raw := range []string{ - "http://api.example.com/v1", "https://user:pass@api.example.com/v1", - "https://127.0.0.1/v1", "https://169.254.169.254/latest", "https://localhost/v1", + "ftp://api.example.com/v1", "http://user:pass@api.example.com/v1", "https://user:pass@api.example.com/v1", + "http://api.example.com/v1?token=fake", "https://api.example.com/v1#fragment", + "http://127.0.0.1/v1", "https://127.0.0.1/v1", "http://169.254.169.254/latest", + "https://169.254.169.254/latest", "http://localhost/v1", "https://localhost/v1", } { if _, err := policy.ValidateSyntax(raw); err == nil { t.Errorf("危险地址 %q 应被拒绝", raw) } } - got, err := policy.ValidateSyntax("https://api.example.com/v1/") - if err != nil || got != "https://api.example.com/v1" { - t.Fatalf("公网 HTTPS 地址应通过并去掉尾斜杠: %q %v", got, err) + for raw, want := range map[string]string{ + "http://api.example.com/v1/": "http://api.example.com/v1", + "HTTPS://api.example.com/v1/": "https://api.example.com/v1", + } { + got, err := policy.ValidateSyntax(raw) + if err != nil || got != want { + t.Errorf("公网 HTTP/HTTPS 地址应通过并规范化: raw=%q got=%q err=%v", raw, got, err) + } } allowed := NewAIEndpointPolicy([]string{"10.0.0.8"}) - if _, err := allowed.ValidateSyntax("https://10.0.0.8/v1"); err != nil { - t.Fatalf("部署允许的私有端点应通过: %v", err) + for _, raw := range []string{"http://10.0.0.8/v1", "https://10.0.0.8/v1"} { + if _, err := allowed.ValidateSyntax(raw); err != nil { + t.Errorf("现有部署允许列表中的私有 HTTP/HTTPS 端点应通过: %q %v", raw, err) + } + } +} + +func TestSafeAIHTTPClient_可调用允许列表中的HTTP端点(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v1/chat/completions" { + t.Errorf("请求路径 = %q", request.URL.Path) + } + if request.Header.Get("Authorization") != "Bearer fake-http-key" { + t.Errorf("Authorization 未按既有方式发送") + } + response.WriteHeader(http.StatusOK) + })) + defer server.Close() + + policy := NewAIEndpointPolicy([]string{"127.0.0.1"}) + if err := policy.ValidateResolved(context.Background(), server.URL+"/v1"); err != nil { + t.Fatalf("测试级允许列表中的 HTTP 地址应通过完整校验: %v", err) + } + client := NewSafeAIHTTPClient(policy, time.Second) + if err := callAIHealthCheck(context.Background(), client, model.AIProviderConfig{ + BaseURL: server.URL + "/v1", Model: "test-model", + }, "fake-http-key"); err != nil { + t.Fatalf("HTTP OpenAI 兼容端点调用失败: %v", err) } } diff --git a/admin/templates/ai/list.html b/admin/templates/ai/list.html index 066351a..88d329b 100644 --- a/admin/templates/ai/list.html +++ b/admin/templates/ai/list.html @@ -7,7 +7,7 @@ - + @@ -24,7 +24,7 @@ {{if .Message}}
{{.Message}}
{{end}} {{if .Error}}{{.Error}}
{{end}} {{if .SecretStoreError}}密钥存储不可用:{{.SecretStoreError}}
{{end}} -API Key 只写入部署指定的独立 600 权限密钥文件。页面永不回显明文;修改普通配置或密钥后必须重新测试才能启用。
+Base URL 支持 HTTP 和 HTTPS;HTTP 会明文传输 API Key 和匹配请求,请只在可信网络使用。API Key 只写入部署指定的独立 600 权限密钥文件,页面永不回显明文;修改普通配置或密钥后必须重新测试才能启用。