mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-28 00:07:33 +00:00
feat: add OAuth outbound transport plugin system
This commit is contained in:
@@ -32,7 +32,7 @@ const (
|
||||
|
||||
// DefaultCSPPolicy is the default Content-Security-Policy with nonce support
|
||||
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware
|
||||
const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src 'self' https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
|
||||
// UMQ(用户消息队列)模式常量
|
||||
const (
|
||||
@@ -99,6 +99,18 @@ type Config struct {
|
||||
Idempotency IdempotencyConfig `mapstructure:"idempotency"`
|
||||
BatchImage BatchImageConfig `mapstructure:"batch_image"`
|
||||
ImageStorage ImageStorageConfig `mapstructure:"image_storage"`
|
||||
Plugins PluginConfig `mapstructure:"plugins"`
|
||||
}
|
||||
|
||||
// PluginConfig 控制管理员手动上传的本地进程插件。
|
||||
// 默认不包含插件,也不允许安装未签名插件;TrustedPublishers 用于追加第三方发布者。
|
||||
type PluginConfig struct {
|
||||
DataDir string `mapstructure:"data_dir"`
|
||||
AllowUnsigned bool `mapstructure:"allow_unsigned"`
|
||||
TrustedPublishers map[string]string `mapstructure:"trusted_publishers"`
|
||||
MaxUploadBytes int64 `mapstructure:"max_upload_bytes"`
|
||||
MaxUncompressedBytes int64 `mapstructure:"max_uncompressed_bytes"`
|
||||
StartTimeoutSeconds int `mapstructure:"start_timeout_seconds"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
@@ -2269,6 +2281,14 @@ func setDefaults() {
|
||||
viper.SetDefault("pricing.update_interval_hours", 24)
|
||||
viper.SetDefault("pricing.hash_check_interval_minutes", 10)
|
||||
|
||||
// 本地进程插件。插件必须由管理员手动上传,项目默认不携带任何插件能力。
|
||||
viper.SetDefault("plugins.data_dir", "")
|
||||
viper.SetDefault("plugins.allow_unsigned", false)
|
||||
viper.SetDefault("plugins.trusted_publishers", map[string]string{})
|
||||
viper.SetDefault("plugins.max_upload_bytes", int64(128*1024*1024))
|
||||
viper.SetDefault("plugins.max_uncompressed_bytes", int64(256*1024*1024))
|
||||
viper.SetDefault("plugins.start_timeout_seconds", 15)
|
||||
|
||||
// Timezone (default to Asia/Shanghai for Chinese users)
|
||||
viper.SetDefault("timezone", "Asia/Shanghai")
|
||||
|
||||
@@ -2625,6 +2645,15 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("security.proxy_probe.urls: %w", err)
|
||||
}
|
||||
c.Security.ProxyProbe.URLs = proxyProbeURLs
|
||||
if c.Plugins.MaxUploadBytes <= 0 || c.Plugins.MaxUploadBytes > 1024*1024*1024 {
|
||||
return fmt.Errorf("plugins.max_upload_bytes must be between 1 and 1073741824")
|
||||
}
|
||||
if c.Plugins.MaxUncompressedBytes < c.Plugins.MaxUploadBytes || c.Plugins.MaxUncompressedBytes > 2*1024*1024*1024 {
|
||||
return fmt.Errorf("plugins.max_uncompressed_bytes must be between max_upload_bytes and 2147483648")
|
||||
}
|
||||
if c.Plugins.StartTimeoutSeconds < 1 || c.Plugins.StartTimeoutSeconds > 120 {
|
||||
return fmt.Errorf("plugins.start_timeout_seconds must be between 1 and 120")
|
||||
}
|
||||
if c.Server.ReadHeaderTimeout < 1 || c.Server.ReadHeaderTimeout > 60 {
|
||||
return fmt.Errorf("server.read_header_timeout must be between 1 and 60 seconds")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const pluginUISessionTTL = 30 * time.Minute
|
||||
|
||||
// PluginHandler 提供插件安装、生命周期、配置和隔离 UI 资源接口。
|
||||
type PluginHandler struct {
|
||||
manager *service.PluginManager
|
||||
}
|
||||
|
||||
func NewPluginHandler(manager *service.PluginManager) *PluginHandler {
|
||||
return &PluginHandler{manager: manager}
|
||||
}
|
||||
|
||||
func (h *PluginHandler) List(c *gin.Context) {
|
||||
plugins, err := h.manager.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, plugins)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Get(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
plugin, err := h.manager.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, plugin)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Upload(c *gin.Context) {
|
||||
maxBytes := h.manager.MaxUploadBytes()
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+(1<<20))
|
||||
file, header, err := c.Request.FormFile("plugin")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "请选择有效的 .s2plugin 文件")
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
if !strings.HasSuffix(strings.ToLower(header.Filename), ".s2plugin") {
|
||||
response.BadRequest(c, "插件包扩展名必须是 .s2plugin")
|
||||
return
|
||||
}
|
||||
var installedBy *int64
|
||||
if subject, ok := middleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 {
|
||||
userID := subject.UserID
|
||||
installedBy = &userID
|
||||
}
|
||||
plugin, err := h.manager.Install(c.Request.Context(), file, installedBy)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, plugin)
|
||||
}
|
||||
|
||||
type pluginEnableRequest struct {
|
||||
AcceptUntested bool `json:"accept_untested"`
|
||||
RolloutPercent int `json:"rollout_percent"`
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Enable(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
request := pluginEnableRequest{RolloutPercent: 100}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.BadRequest(c, "启用参数无效")
|
||||
return
|
||||
}
|
||||
plugin, err := h.manager.Enable(c.Request.Context(), id, request.AcceptUntested, request.RolloutPercent)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, plugin)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Disable(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
plugin, err := h.manager.Disable(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, plugin)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Delete(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.manager.Delete(c.Request.Context(), id); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"message": "插件已卸载"})
|
||||
}
|
||||
|
||||
func (h *PluginHandler) GetConfig(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
configJSON, err := h.manager.GetConfig(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", configJSON)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) SaveConfig(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 4*1024*1024))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
response.BadRequest(c, "插件配置必须是有效 JSON")
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
response.BadRequest(c, "插件配置只能包含一个 JSON 值")
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "插件配置无法序列化")
|
||||
return
|
||||
}
|
||||
saved, err := h.manager.SaveConfig(c.Request.Context(), id, raw)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", saved)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Test(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.manager.Test(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) CreateUISession(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
assetToken, expires, err := h.manager.CreateUIAssetToken(c.Request.Context(), id, pluginUISessionTTL)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
bridgeToken, err := randomPluginToken()
|
||||
if err != nil {
|
||||
response.InternalError(c, "创建插件 UI Bridge 失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{
|
||||
"url": fmt.Sprintf("/api/v1/plugin-ui/%s/index.html#bridge_token=%s", assetToken, bridgeToken),
|
||||
"bridge_token": bridgeToken,
|
||||
"ui_bridge_version": 1,
|
||||
"expires_at": expires,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeUIAsset 使用短时随机能力 URL 提供插件静态资源,不向 iframe 暴露管理员凭据。
|
||||
func (h *PluginHandler) ServeUIAsset(c *gin.Context) {
|
||||
token := strings.TrimSpace(c.Param("token"))
|
||||
pluginID, err := h.manager.ResolveUIAssetToken(token)
|
||||
if err != nil {
|
||||
c.Status(http.StatusGone)
|
||||
return
|
||||
}
|
||||
relative := strings.TrimPrefix(c.Param("path"), "/")
|
||||
data, logicalPath, err := h.manager.ReadUIAsset(c.Request.Context(), pluginID, relative)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
contentType := mime.TypeByExtension(filepath.Ext(logicalPath))
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.Header("Referrer-Policy", "no-referrer")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "SAMEORIGIN")
|
||||
// sandbox iframe 没有 allow-same-origin,会以不透明来源加载自己的 CSS/JS。
|
||||
// 资源 URL 由短时随机能力 Token 保护,Bridge Token 只存在于 fragment 中。
|
||||
c.Header("Cross-Origin-Resource-Policy", "cross-origin")
|
||||
c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'")
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
func pluginIDParam(c *gin.Context) (int64, bool) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.BadRequest(c, "插件 ID 无效")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func randomPluginToken() (string, error) {
|
||||
buffer := make([]byte, 32)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buffer), nil
|
||||
}
|
||||
@@ -382,9 +382,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
ModelPlazaDescription: settings.ModelPlazaDescription,
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
PluginManagementEnabled: settings.PluginManagementEnabled,
|
||||
ModelPlazaDescription: settings.ModelPlazaDescription,
|
||||
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
|
||||
|
||||
@@ -347,6 +347,9 @@ type UpdateSettingsRequest struct {
|
||||
ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaDescription *string `json:"model_plaza_description"`
|
||||
|
||||
// Plugin management menu visibility switch; plugin runtime is unaffected.
|
||||
PluginManagementEnabled *bool `json:"plugin_management_enabled"`
|
||||
|
||||
// Affiliate (邀请返利) feature switch
|
||||
AffiliateEnabled *bool `json:"affiliate_enabled"`
|
||||
|
||||
@@ -1938,6 +1941,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.ModelPlazaDescription
|
||||
}(),
|
||||
PluginManagementEnabled: func() bool {
|
||||
if req.PluginManagementEnabled != nil {
|
||||
return *req.PluginManagementEnabled
|
||||
}
|
||||
return previousSettings.PluginManagementEnabled
|
||||
}(),
|
||||
AffiliateEnabled: func() bool {
|
||||
if req.AffiliateEnabled != nil {
|
||||
return *req.AffiliateEnabled
|
||||
@@ -2357,9 +2366,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
|
||||
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
|
||||
|
||||
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
|
||||
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
|
||||
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
|
||||
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
|
||||
PluginManagementEnabled: updatedSettings.PluginManagementEnabled,
|
||||
|
||||
AffiliateEnabled: updatedSettings.AffiliateEnabled,
|
||||
|
||||
|
||||
@@ -316,9 +316,10 @@ type SystemSettings struct {
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
// Model Plaza feature (public group/model pricing showcase)
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaDescription string `json:"model_plaza_description"`
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaDescription string `json:"model_plaza_description"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
|
||||
// 风控中心功能开关
|
||||
RiskControlEnabled bool `json:"risk_control_enabled"`
|
||||
@@ -418,8 +419,9 @@ type PublicSettings struct {
|
||||
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
|
||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ type AdminHandlers struct {
|
||||
UserAttribute *admin.UserAttributeHandler
|
||||
ErrorPassthrough *admin.ErrorPassthroughHandler
|
||||
TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
|
||||
Plugin *admin.PluginHandler
|
||||
APIKey *admin.AdminAPIKeyHandler
|
||||
ScheduledTest *admin.ScheduledTestHandler
|
||||
Channel *admin.ChannelHandler
|
||||
|
||||
@@ -111,8 +111,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
PluginManagementEnabled: settings.PluginManagementEnabled,
|
||||
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ func ProvideAdminHandlers(
|
||||
userAttributeHandler *admin.UserAttributeHandler,
|
||||
errorPassthroughHandler *admin.ErrorPassthroughHandler,
|
||||
tlsFingerprintProfileHandler *admin.TLSFingerprintProfileHandler,
|
||||
pluginHandler *admin.PluginHandler,
|
||||
apiKeyHandler *admin.AdminAPIKeyHandler,
|
||||
scheduledTestHandler *admin.ScheduledTestHandler,
|
||||
channelHandler *admin.ChannelHandler,
|
||||
@@ -76,6 +77,7 @@ func ProvideAdminHandlers(
|
||||
UserAttribute: userAttributeHandler,
|
||||
ErrorPassthrough: errorPassthroughHandler,
|
||||
TLSFingerprintProfile: tlsFingerprintProfileHandler,
|
||||
Plugin: pluginHandler,
|
||||
APIKey: apiKeyHandler,
|
||||
ScheduledTest: scheduledTestHandler,
|
||||
Channel: channelHandler,
|
||||
@@ -117,6 +119,7 @@ func ProvideGatewayHandler(
|
||||
|
||||
func ProvideOpenAIGatewayHandler(
|
||||
gatewayService *service.OpenAIGatewayService,
|
||||
pluginManager *service.PluginManager,
|
||||
concurrencyService *service.ConcurrencyService,
|
||||
billingCacheService *service.BillingCacheService,
|
||||
apiKeyService *service.APIKeyService,
|
||||
@@ -128,6 +131,7 @@ func ProvideOpenAIGatewayHandler(
|
||||
cfg *config.Config,
|
||||
coordinator *securityaudit.Coordinator,
|
||||
) *OpenAIGatewayHandler {
|
||||
gatewayService.SetPluginManager(pluginManager)
|
||||
h := NewOpenAIGatewayHandler(gatewayService, concurrencyService, billingCacheService, apiKeyService,
|
||||
usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, cfg)
|
||||
h.securityAuditCoordinator = coordinator
|
||||
@@ -267,6 +271,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewUserAttributeHandler,
|
||||
admin.NewErrorPassthroughHandler,
|
||||
admin.NewTLSFingerprintProfileHandler,
|
||||
admin.NewPluginHandler,
|
||||
admin.NewAdminAPIKeyHandler,
|
||||
admin.NewScheduledTestHandler,
|
||||
admin.NewChannelHandler,
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
type pluginRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewPluginRepository(db *sql.DB) service.PluginRepository {
|
||||
return &pluginRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pluginRepository) List(ctx context.Context) ([]*service.PluginInstallation, error) {
|
||||
rows, err := r.db.QueryContext(ctx, pluginSelectSQL+` ORDER BY installed_at DESC, id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
plugins := make([]*service.PluginInstallation, 0)
|
||||
for rows.Next() {
|
||||
plugin, scanErr := scanPlugin(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
plugins = append(plugins, plugin)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, plugin := range plugins {
|
||||
bindings, bindingErr := r.listBindings(ctx, plugin.ID)
|
||||
if bindingErr != nil {
|
||||
return nil, bindingErr
|
||||
}
|
||||
plugin.Bindings = bindings
|
||||
}
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
func (r *pluginRepository) GetByID(ctx context.Context, id int64) (*service.PluginInstallation, error) {
|
||||
plugin, err := scanPlugin(r.db.QueryRowContext(ctx, pluginSelectSQL+` WHERE id = $1`, id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plugin.Bindings, err = r.listBindings(ctx, plugin.ID)
|
||||
return plugin, err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) GetByKey(ctx context.Context, key string) (*service.PluginInstallation, error) {
|
||||
plugin, err := scanPlugin(r.db.QueryRowContext(ctx, pluginSelectSQL+` WHERE plugin_key = $1`, key))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plugin.Bindings, err = r.listBindings(ctx, plugin.ID)
|
||||
return plugin, err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Install(ctx context.Context, plugin *service.PluginInstallation, bindings []service.PluginBinding) (*service.PluginInstallation, error) {
|
||||
manifestJSON, err := json.Marshal(plugin.Manifest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化插件清单: %w", err)
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO sub2api_plugin_installations (
|
||||
plugin_key, name, version, description, author, manifest, artifact_data,
|
||||
artifact_path, install_path, binary_path, binary_sha256,
|
||||
signature_status, state, last_error, installed_by, installed_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13, '', $14, NOW(), NOW())
|
||||
ON CONFLICT (plugin_key) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
version = EXCLUDED.version,
|
||||
description = EXCLUDED.description,
|
||||
author = EXCLUDED.author,
|
||||
manifest = EXCLUDED.manifest,
|
||||
artifact_data = EXCLUDED.artifact_data,
|
||||
artifact_path = EXCLUDED.artifact_path,
|
||||
install_path = EXCLUDED.install_path,
|
||||
binary_path = EXCLUDED.binary_path,
|
||||
binary_sha256 = EXCLUDED.binary_sha256,
|
||||
signature_status = EXCLUDED.signature_status,
|
||||
state = EXCLUDED.state,
|
||||
last_error = '',
|
||||
installed_by = EXCLUDED.installed_by,
|
||||
installed_at = NOW(),
|
||||
enabled_at = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE sub2api_plugin_installations.state IN ('disabled', 'error', 'incompatible')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sub2api_plugin_bindings b
|
||||
WHERE b.plugin_id = sub2api_plugin_installations.id AND b.enabled = TRUE
|
||||
)
|
||||
RETURNING id
|
||||
`, plugin.PluginKey, plugin.Name, plugin.Version, plugin.Description, plugin.Author, manifestJSON, plugin.ArtifactData,
|
||||
plugin.ArtifactPath, plugin.InstallPath, plugin.BinaryPath, plugin.BinarySHA256,
|
||||
plugin.SignatureStatus, plugin.State, plugin.InstalledBy)
|
||||
var id int64
|
||||
if err := row.Scan(&id); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, service.ErrPluginStateChanged
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := replacePluginBindings(ctx, tx, id, bindings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (r *pluginRepository) GetArtifact(ctx context.Context, id int64) ([]byte, error) {
|
||||
var artifact []byte
|
||||
err := r.db.QueryRowContext(ctx, `SELECT artifact_data FROM sub2api_plugin_installations WHERE id = $1`, id).Scan(&artifact)
|
||||
return artifact, err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Delete(ctx context.Context, id int64, expectedBinarySHA256 string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
DELETE FROM sub2api_plugin_installations p
|
||||
WHERE p.id = $1 AND p.binary_sha256 = $2 AND p.state NOT IN ('starting', 'enabled')
|
||||
AND NOT EXISTS (SELECT 1 FROM sub2api_plugin_bindings b WHERE b.plugin_id = p.id AND b.enabled = TRUE)
|
||||
`, id, expectedBinarySHA256)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 1 {
|
||||
return service.ErrPluginStateChanged
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pluginRepository) BeginEnable(ctx context.Context, id int64, binarySHA256, expectedState string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE sub2api_plugin_installations
|
||||
SET state = 'starting', last_error = '', updated_at = NOW()
|
||||
WHERE id = $1 AND binary_sha256 = $2 AND state = $3 AND state <> 'starting'
|
||||
`, id, binarySHA256, expectedState)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 1 {
|
||||
return service.ErrPluginStateChanged
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pluginRepository) MarkRuntimeHealthy(ctx context.Context, id int64, binarySHA256, configEncrypted string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE sub2api_plugin_installations p
|
||||
SET state = 'enabled', last_error = '', enabled_at = COALESCE(enabled_at, NOW()), updated_at = NOW()
|
||||
WHERE p.id = $1 AND p.binary_sha256 = $2 AND p.config_encrypted = $3
|
||||
AND EXISTS (SELECT 1 FROM sub2api_plugin_bindings b WHERE b.plugin_id = p.id AND b.enabled = TRUE)
|
||||
`, id, binarySHA256, configEncrypted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 1 {
|
||||
return service.ErrPluginStateChanged
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pluginRepository) UpdateState(ctx context.Context, id int64, state, lastError string, enabledAt *time.Time, expectedBinarySHA256, expectedState string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE sub2api_plugin_installations
|
||||
SET state = $2, last_error = $3, enabled_at = $4, updated_at = NOW()
|
||||
WHERE id = $1 AND binary_sha256 = $5 AND state = $6
|
||||
`, id, state, lastError, enabledAt, expectedBinarySHA256, expectedState)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 1 {
|
||||
return service.ErrPluginStateChanged
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pluginRepository) UpdateConfig(ctx context.Context, id int64, encrypted, expectedBinarySHA256 string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE sub2api_plugin_installations
|
||||
SET config_encrypted = $2, updated_at = NOW()
|
||||
WHERE id = $1 AND binary_sha256 = $3
|
||||
`, id, encrypted, expectedBinarySHA256)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 1 {
|
||||
return service.ErrPluginStateChanged
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pluginRepository) UpdateBindingsAndState(
|
||||
ctx context.Context,
|
||||
pluginID int64,
|
||||
bindings []service.PluginBinding,
|
||||
state string,
|
||||
lastError string,
|
||||
enabledAt *time.Time,
|
||||
expectedState string,
|
||||
expectedBinarySHA256 string,
|
||||
) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE sub2api_plugin_installations
|
||||
SET state = $2, last_error = $3, enabled_at = $4, updated_at = NOW()
|
||||
WHERE id = $1 AND ($5 = '' OR state = $5) AND binary_sha256 = $6
|
||||
`, pluginID, state, lastError, enabledAt, expectedState, expectedBinarySHA256)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 1 {
|
||||
return service.ErrPluginStateChanged
|
||||
}
|
||||
if err := replacePluginBindings(ctx, tx, pluginID, bindings); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type pluginBindingExecutor interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
func replacePluginBindings(ctx context.Context, executor pluginBindingExecutor, pluginID int64, bindings []service.PluginBinding) error {
|
||||
if _, err := executor.ExecContext(ctx, `DELETE FROM sub2api_plugin_bindings WHERE plugin_id = $1`, pluginID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
if _, err := executor.ExecContext(ctx, `
|
||||
INSERT INTO sub2api_plugin_bindings (
|
||||
plugin_id, capability, platform, account_type, enabled, rollout_percent, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
`, pluginID, binding.Capability, binding.Platform, binding.AccountType, binding.Enabled, binding.RolloutPercent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const pluginSelectSQL = `
|
||||
SELECT id, plugin_key, name, version, description, author, manifest,
|
||||
artifact_path, install_path, binary_path, binary_sha256,
|
||||
signature_status, state, config_encrypted, last_error,
|
||||
installed_by, installed_at, enabled_at, updated_at
|
||||
FROM sub2api_plugin_installations`
|
||||
|
||||
type pluginScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanPlugin(scanner pluginScanner) (*service.PluginInstallation, error) {
|
||||
plugin := &service.PluginInstallation{}
|
||||
var manifestJSON []byte
|
||||
if err := scanner.Scan(
|
||||
&plugin.ID, &plugin.PluginKey, &plugin.Name, &plugin.Version, &plugin.Description,
|
||||
&plugin.Author, &manifestJSON, &plugin.ArtifactPath, &plugin.InstallPath,
|
||||
&plugin.BinaryPath, &plugin.BinarySHA256, &plugin.SignatureStatus, &plugin.State,
|
||||
&plugin.ConfigEncrypted, &plugin.LastError, &plugin.InstalledBy, &plugin.InstalledAt,
|
||||
&plugin.EnabledAt, &plugin.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(manifestJSON, &plugin.Manifest); err != nil {
|
||||
return nil, fmt.Errorf("解析插件清单: %w", err)
|
||||
}
|
||||
return plugin, nil
|
||||
}
|
||||
|
||||
func (r *pluginRepository) listBindings(ctx context.Context, pluginID int64) ([]service.PluginBinding, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, plugin_id, capability, platform, account_type, enabled,
|
||||
rollout_percent, created_at, updated_at
|
||||
FROM sub2api_plugin_bindings WHERE plugin_id = $1 ORDER BY id
|
||||
`, pluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
bindings := make([]service.PluginBinding, 0)
|
||||
for rows.Next() {
|
||||
var binding service.PluginBinding
|
||||
if err := rows.Scan(&binding.ID, &binding.PluginID, &binding.Capability, &binding.Platform,
|
||||
&binding.AccountType, &binding.Enabled, &binding.RolloutPercent,
|
||||
&binding.CreatedAt, &binding.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bindings = append(bindings, binding)
|
||||
}
|
||||
return bindings, rows.Err()
|
||||
}
|
||||
|
||||
var _ service.PluginRepository = (*pluginRepository)(nil)
|
||||
@@ -0,0 +1,70 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPluginRepositoryLifecycleIsAtomicAndOptimistic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := &pluginRepository{db: integrationDB}
|
||||
pluginKey := "local.test.repository-" + strings.ToLower(time.Now().Format("150405.000000000"))
|
||||
defer func() {
|
||||
_, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_installations WHERE plugin_key = $1`, pluginKey)
|
||||
}()
|
||||
|
||||
manifest := service.PluginManifest{SchemaVersion: 1, ID: pluginKey, Name: "测试插件", Version: "1.0.0"}
|
||||
first := &service.PluginInstallation{
|
||||
PluginKey: pluginKey, Name: "测试插件", Version: "1.0.0", Manifest: manifest,
|
||||
ArtifactData: []byte("first-package"), ArtifactPath: "/tmp/first.s2plugin",
|
||||
InstallPath: "/tmp/first", BinaryPath: "/tmp/first/plugin",
|
||||
BinarySHA256: strings.Repeat("a", 64), SignatureStatus: service.PluginSignatureTrusted,
|
||||
State: service.PluginStateDisabled,
|
||||
}
|
||||
bindings := []service.PluginBinding{{
|
||||
Capability: service.PluginCapabilityOpenAIOAuthOutbound,
|
||||
Platform: service.PlatformOpenAI, AccountType: service.AccountTypeOAuth,
|
||||
RolloutPercent: 100,
|
||||
}}
|
||||
installed, err := repo.Install(ctx, first, bindings)
|
||||
require.NoError(t, err)
|
||||
artifact, err := repo.GetArtifact(ctx, installed.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first.ArtifactData, artifact)
|
||||
|
||||
require.NoError(t, repo.BeginEnable(ctx, installed.ID, first.BinarySHA256, service.PluginStateDisabled))
|
||||
bindings[0].Enabled = true
|
||||
now := time.Now()
|
||||
require.NoError(t, repo.UpdateBindingsAndState(
|
||||
ctx, installed.ID, bindings, service.PluginStateEnabled, "", &now,
|
||||
service.PluginStateStarting, first.BinarySHA256,
|
||||
))
|
||||
|
||||
second := *first
|
||||
second.Version = "1.1.0"
|
||||
second.Manifest.Version = second.Version
|
||||
second.BinarySHA256 = strings.Repeat("b", 64)
|
||||
second.ArtifactData = []byte("second-package")
|
||||
_, err = repo.Install(ctx, &second, bindings)
|
||||
require.ErrorIs(t, err, service.ErrPluginStateChanged)
|
||||
|
||||
bindings[0].Enabled = false
|
||||
require.NoError(t, repo.UpdateBindingsAndState(
|
||||
ctx, installed.ID, bindings, service.PluginStateDisabled, "", nil, "", first.BinarySHA256,
|
||||
))
|
||||
replaced, err := repo.Install(ctx, &second, bindings)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, installed.ID, replaced.ID)
|
||||
|
||||
err = repo.Delete(ctx, replaced.ID, first.BinarySHA256)
|
||||
require.True(t, errors.Is(err, service.ErrPluginStateChanged))
|
||||
require.NoError(t, repo.Delete(ctx, replaced.ID, second.BinarySHA256))
|
||||
}
|
||||
@@ -96,6 +96,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewUserGroupRateRepository,
|
||||
NewErrorPassthroughRepository,
|
||||
NewTLSFingerprintProfileRepository,
|
||||
NewPluginRepository,
|
||||
NewChannelRepository,
|
||||
NewChannelMonitorRepository,
|
||||
NewChannelMonitorV2Repository,
|
||||
|
||||
@@ -53,6 +53,9 @@ var requiredCSPDirectiveValues = []struct {
|
||||
directive string
|
||||
value string
|
||||
}{
|
||||
// 插件配置 UI 使用同源 iframe;目标响应仍必须显式放开 X-Frame-Options,
|
||||
// 因此这里只允许 'self' 不会使其他默认 DENY 的管理/API 页面可被嵌入。
|
||||
{"frame-src", "'self'"},
|
||||
{"script-src", CloudflareInsightsDomain},
|
||||
{"script-src", TencentCaptchaDomain},
|
||||
{"frame-src", TencentCaptchaDomain},
|
||||
@@ -196,35 +199,41 @@ func directiveHasValue(policy, directive, value string) bool {
|
||||
// addToDirective adds a value to a specific CSP directive.
|
||||
// If the directive doesn't exist, it will be added after default-src.
|
||||
func addToDirective(policy, directive, value string) string {
|
||||
// Find the directive in the policy
|
||||
directivePrefix := directive + " "
|
||||
idx := strings.Index(policy, directivePrefix)
|
||||
|
||||
if idx == -1 {
|
||||
// Directive not found, add it after default-src or at the beginning
|
||||
defaultSrcIdx := strings.Index(policy, "default-src ")
|
||||
if defaultSrcIdx != -1 {
|
||||
// Find the end of default-src directive (next semicolon)
|
||||
endIdx := strings.Index(policy[defaultSrcIdx:], ";")
|
||||
if endIdx != -1 {
|
||||
insertPos := defaultSrcIdx + endIdx + 1
|
||||
// Insert new directive after default-src
|
||||
return policy[:insertPos] + " " + directive + " 'self' " + value + ";" + policy[insertPos:]
|
||||
}
|
||||
}
|
||||
// Fallback: prepend the directive
|
||||
return directive + " 'self' " + value + "; " + policy
|
||||
if end, ok := cspDirectiveEnd(policy, directive); ok {
|
||||
return policy[:end] + " " + value + policy[end:]
|
||||
}
|
||||
|
||||
// Find the end of this directive (next semicolon or end of string)
|
||||
endIdx := strings.Index(policy[idx:], ";")
|
||||
|
||||
if endIdx == -1 {
|
||||
// No semicolon found, directive goes to end of string
|
||||
return policy + " " + value
|
||||
trimmed := strings.TrimSpace(policy)
|
||||
if trimmed == "" {
|
||||
return newCSPDirective(directive, value)
|
||||
}
|
||||
if !strings.HasSuffix(trimmed, ";") {
|
||||
trimmed += ";"
|
||||
}
|
||||
return trimmed + " " + newCSPDirective(directive, value)
|
||||
}
|
||||
|
||||
// Insert value before the semicolon
|
||||
insertPos := idx + endIdx
|
||||
return policy[:insertPos] + " " + value + policy[insertPos:]
|
||||
func cspDirectiveEnd(policy, directive string) (int, bool) {
|
||||
start := 0
|
||||
for start <= len(policy) {
|
||||
end := len(policy)
|
||||
if relativeEnd := strings.IndexByte(policy[start:], ';'); relativeEnd >= 0 {
|
||||
end = start + relativeEnd
|
||||
}
|
||||
fields := strings.Fields(policy[start:end])
|
||||
if len(fields) > 0 && fields[0] == directive {
|
||||
return end, true
|
||||
}
|
||||
if end == len(policy) {
|
||||
break
|
||||
}
|
||||
start = end + 1
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func newCSPDirective(directive, value string) string {
|
||||
if value == "'self'" {
|
||||
return directive + " 'self';"
|
||||
}
|
||||
return directive + " 'self' " + value + ";"
|
||||
}
|
||||
|
||||
@@ -132,6 +132,25 @@ func TestSecurityHeaders(t *testing.T) {
|
||||
assert.Equal(t, 1, countDirectiveValue(csp, "worker-src", TencentCaptchaWorkerSource))
|
||||
})
|
||||
|
||||
t.Run("old_custom_policy_dynamically_allows_same_origin_frames", func(t *testing.T) {
|
||||
cfg := config.CSPConfig{
|
||||
Enabled: true,
|
||||
Policy: "default-src 'self'; frame-src https://checkout.example.com",
|
||||
}
|
||||
middleware := SecurityHeaders(cfg, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/admin/plugins", nil)
|
||||
|
||||
middleware(c)
|
||||
|
||||
csp := w.Header().Get("Content-Security-Policy")
|
||||
assert.Equal(t, 1, countDirectiveValue(csp, "frame-src", "'self'"))
|
||||
assert.Equal(t, 1, countDirectiveValue(csp, "frame-src", "https://checkout.example.com"))
|
||||
assert.Equal(t, "DENY", w.Header().Get("X-Frame-Options"))
|
||||
})
|
||||
|
||||
t.Run("api_route_skips_csp_nonce_generation", func(t *testing.T) {
|
||||
cfg := config.CSPConfig{
|
||||
Enabled: true,
|
||||
@@ -298,6 +317,21 @@ func TestEnhanceCSPPolicy(t *testing.T) {
|
||||
assert.Contains(t, enhanced, CloudflareInsightsDomain)
|
||||
})
|
||||
|
||||
t.Run("allows_only_same_origin_plugin_frames", func(t *testing.T) {
|
||||
policy := "default-src 'self'; frame-src https://checkout.example.com"
|
||||
enhanced := enhanceCSPPolicy(policy)
|
||||
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", "'self'"))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", "https://checkout.example.com"))
|
||||
assert.NotContains(t, enhanced, "frame-src *")
|
||||
})
|
||||
|
||||
t.Run("adds_same_origin_frame_source_once_when_directive_is_missing", func(t *testing.T) {
|
||||
enhanced := enhanceCSPPolicy("default-src 'self'; script-src 'self'")
|
||||
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", "'self'"))
|
||||
})
|
||||
|
||||
t.Run("does_not_duplicate_nonce_placeholder", func(t *testing.T) {
|
||||
policy := "default-src 'self'; script-src 'self' __CSP_NONCE__"
|
||||
enhanced := enhanceCSPPolicy(policy)
|
||||
@@ -449,6 +483,15 @@ func TestAddToDirective(t *testing.T) {
|
||||
assert.Contains(t, result, "script-src")
|
||||
assert.Contains(t, result, "https://example.com")
|
||||
})
|
||||
|
||||
t.Run("does_not_match_a_directive_name_suffix", func(t *testing.T) {
|
||||
policy := "default-src 'none'; child-frame-src https://legacy.example.com"
|
||||
result := addToDirective(policy, "frame-src", "'self'")
|
||||
|
||||
assert.Equal(t, 1, countDirectiveValue(result, "frame-src", "'self'"))
|
||||
assert.Equal(t, 0, countDirectiveValue(result, "child-frame-src", "'self'"))
|
||||
assert.Equal(t, 1, countDirectiveValue(result, "child-frame-src", "https://legacy.example.com"))
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
|
||||
@@ -20,6 +20,9 @@ func RegisterAdminRoutes(
|
||||
settingService *service.SettingService,
|
||||
panelRateLimiter *middleware.PanelRateLimiter,
|
||||
) {
|
||||
// 插件 UI 使用短时能力 URL,仅提供经过安装校验的静态资源。
|
||||
v1.GET("/plugin-ui/:token/*path", h.Admin.Plugin.ServeUIAsset)
|
||||
|
||||
admin := v1.Group("/admin")
|
||||
admin.Use(gin.HandlerFunc(adminAuth))
|
||||
// 面板全局按用户限流(默认管理员豁免,可在系统设置中关闭豁免)
|
||||
@@ -100,6 +103,9 @@ func RegisterAdminRoutes(
|
||||
// TLS 指纹模板管理
|
||||
registerTLSFingerprintProfileRoutes(admin, h)
|
||||
|
||||
// 本地进程插件管理
|
||||
registerPluginRoutes(admin, h, stepUpAuth)
|
||||
|
||||
// API Key 管理
|
||||
registerAdminAPIKeyRoutes(admin, h)
|
||||
|
||||
@@ -736,6 +742,22 @@ func registerTLSFingerprintProfileRoutes(admin *gin.RouterGroup, h *handler.Hand
|
||||
}
|
||||
}
|
||||
|
||||
func registerPluginRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAuth middleware.StepUpAuthMiddleware) {
|
||||
plugins := admin.Group("/plugins")
|
||||
{
|
||||
plugins.GET("", h.Admin.Plugin.List)
|
||||
plugins.GET("/:id", h.Admin.Plugin.Get)
|
||||
plugins.POST("/upload", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Upload)
|
||||
plugins.POST("/:id/enable", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Enable)
|
||||
plugins.POST("/:id/disable", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Disable)
|
||||
plugins.DELETE("/:id", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Delete)
|
||||
plugins.GET("/:id/config", h.Admin.Plugin.GetConfig)
|
||||
plugins.PUT("/:id/config", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.SaveConfig)
|
||||
plugins.POST("/:id/test", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Test)
|
||||
plugins.POST("/:id/ui-session", h.Admin.Plugin.CreateUISession)
|
||||
}
|
||||
}
|
||||
|
||||
func registerChannelRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
channels := admin.Group("/channels")
|
||||
{
|
||||
|
||||
@@ -146,6 +146,7 @@ type AccountTestService struct {
|
||||
cfg *config.Config
|
||||
settingService *SettingService
|
||||
tlsFPProfileService *TLSFingerprintProfileService
|
||||
pluginManager *PluginManager
|
||||
agentIdentityTaskMu sync.Mutex
|
||||
agentIdentityWS agentIdentityWSConnectionInvalidator
|
||||
// grokWSDialer is optional; realtime account tests use the default OpenAI-style
|
||||
@@ -159,6 +160,12 @@ func (s *AccountTestService) SetSettingService(settingService *SettingService) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AccountTestService) SetPluginManager(pluginManager *PluginManager) {
|
||||
if s != nil {
|
||||
s.pluginManager = pluginManager
|
||||
}
|
||||
}
|
||||
|
||||
// NewAccountTestService creates a new AccountTestService
|
||||
func NewAccountTestService(
|
||||
accountRepo AccountRepository,
|
||||
@@ -787,7 +794,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, true)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@@ -2124,7 +2131,7 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, true)
|
||||
if err != nil {
|
||||
if s.accountRepo != nil {
|
||||
updates := buildOpenAICompactProbeExtraUpdates(nil, nil, err, false, time.Now())
|
||||
@@ -3022,7 +3029,7 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, false)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Responses API request failed: %s", err.Error()))
|
||||
}
|
||||
|
||||
@@ -517,6 +517,10 @@ const (
|
||||
// the Model Plaza page (global pricing notes, exchange rate, promotions, ...).
|
||||
SettingKeyModelPlazaDescription = "model_plaza_description"
|
||||
|
||||
// SettingKeyPluginManagementEnabled controls sidebar visibility only; it does
|
||||
// not stop or otherwise change already loaded plugin runtimes.
|
||||
SettingKeyPluginManagementEnabled = "plugin_management_enabled"
|
||||
|
||||
// SettingKeyUpstreamBillingProbeSettings stores the global enable switch and interval
|
||||
// for probing remote Sub2API API-key billing metadata.
|
||||
SettingKeyUpstreamBillingProbeSettings = "upstream_billing_probe_settings"
|
||||
|
||||
@@ -74,7 +74,7 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co
|
||||
}
|
||||
|
||||
upstreamStart := time.Now()
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(req, proxyURL, account)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
|
||||
@@ -157,7 +157,7 @@ func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch(
|
||||
SetActualOpenAIUpstreamEndpoint(c, "/v1/responses")
|
||||
|
||||
upstreamStart := time.Now()
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(req, proxyURL, account)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
|
||||
|
||||
@@ -470,15 +470,21 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont
|
||||
req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
|
||||
resp, err = s.httpUpstream.Do(req, request.proxyURL, request.accountID, request.accountConcurrency)
|
||||
} else {
|
||||
client, clientErr := httpclient.GetClient(httpclient.Options{
|
||||
ProxyURL: request.proxyURL,
|
||||
Timeout: codexModelsManifestRequestTimeout,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
})
|
||||
if clientErr != nil {
|
||||
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_PROXY_INVALID", "invalid proxy configuration: %v", clientErr)
|
||||
handled := false
|
||||
if s.pluginManager != nil {
|
||||
resp, handled, err = s.pluginManager.RoundTripOpenAIOAuth(reqCtx, req, request.proxyURL, request.credentialAccount)
|
||||
}
|
||||
if !handled {
|
||||
client, clientErr := httpclient.GetClient(httpclient.Options{
|
||||
ProxyURL: request.proxyURL,
|
||||
Timeout: codexModelsManifestRequestTimeout,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
})
|
||||
if clientErr != nil {
|
||||
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_PROXY_INVALID", "invalid proxy configuration: %v", clientErr)
|
||||
}
|
||||
resp, err = client.Do(req)
|
||||
}
|
||||
resp, err = client.Do(req)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, &codexModelsManifestUpstreamError{
|
||||
|
||||
@@ -92,7 +92,7 @@ func (s *OpenAIGatewayService) ForwardEmbeddings(
|
||||
if account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
setOpsUpstreamError(c, 0, safeErr, "")
|
||||
|
||||
@@ -222,7 +222,7 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest(
|
||||
if account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
|
||||
if account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic(
|
||||
return nil, fmt.Errorf("build upstream request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func (s *OpenAIGatewayService) ForwardResponsesInputTokens(
|
||||
if account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
setOpsUpstreamError(c, 0, safeErr, "")
|
||||
@@ -323,7 +323,7 @@ func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic(
|
||||
if account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
setOpsUpstreamError(c, 0, safeErr, "")
|
||||
|
||||
@@ -938,7 +938,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
|
||||
// Send request
|
||||
upstreamStart := time.Now()
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if headerGuard != nil && headerGuard.stopHeaderWait() {
|
||||
if resp != nil && resp.Body != nil {
|
||||
|
||||
@@ -110,7 +110,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
|
||||
return nil, buildErr
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
|
||||
@@ -1329,7 +1329,7 @@ func (s *OpenAIGatewayService) describeGrokComposerImage(
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
return "", OpenAIUsage{}, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
|
||||
}
|
||||
|
||||
@@ -607,7 +607,7 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses(
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
|
||||
}
|
||||
|
||||
@@ -375,7 +375,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
return nil, fmt.Errorf("build grok retry request: %w", err)
|
||||
}
|
||||
}
|
||||
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func (s *OpenAIGatewayService) forwardAnthropicViaNativeAnthropicEndpoint(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
}
|
||||
|
||||
upstreamStart := time.Now()
|
||||
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if err != nil {
|
||||
// Transport-level failure (proxy/DNS/TCP/TLS — no HTTP response). Convert to
|
||||
|
||||
@@ -122,7 +122,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic(
|
||||
return nil, fmt.Errorf("build upstream request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
|
||||
}
|
||||
|
||||
@@ -420,6 +420,7 @@ type OpenAIGatewayService struct {
|
||||
billingCacheService *BillingCacheService
|
||||
userGroupRateResolver *userGroupRateResolver
|
||||
httpUpstream HTTPUpstream
|
||||
pluginManager *PluginManager
|
||||
deferredService *DeferredService
|
||||
openAITokenProvider *OpenAITokenProvider
|
||||
grokTokenProvider *GrokTokenProvider
|
||||
|
||||
@@ -624,7 +624,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
upstreamStart := time.Now()
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
|
||||
@@ -1787,7 +1787,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
upstreamStart := time.Now()
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
|
||||
@@ -304,7 +304,7 @@ func (s *OpenAIGatewayService) createUpstreamLiveCall(
|
||||
upstreamReq.Header.Set(liveAttestationHeader, attestation)
|
||||
applyLiveUpstreamIdentityHeaders(upstreamReq.Header)
|
||||
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, resolveAccountProxyURL(account), account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, resolveAccountProxyURL(account), account)
|
||||
if err != nil {
|
||||
logLiveCreateStageFailure(ctx, account.ID, "upstream_transport", err)
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package service
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (s *OpenAIGatewayService) SetPluginManager(manager *PluginManager) {
|
||||
s.pluginManager = manager
|
||||
}
|
||||
|
||||
// doOpenAIUpstream 只在 OpenAI OAuth 能力绑定已启用时把真实请求交给插件。
|
||||
// 插件返回标准 http.Response,响应解析、错误映射、SSE 和计费仍由现有核心链处理。
|
||||
func (s *OpenAIGatewayService) doOpenAIUpstream(request *http.Request, proxyURL string, account *Account) (*http.Response, error) {
|
||||
if s.pluginManager != nil {
|
||||
response, handled, err := s.pluginManager.RoundTripOpenAIOAuth(request.Context(), request, proxyURL, account)
|
||||
if handled {
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
return s.httpUpstream.Do(request, proxyURL, account.ID, account.Concurrency)
|
||||
}
|
||||
|
||||
// doOpenAIAccountTestUpstream 让 OpenAI OAuth 账号测试与真实转发使用同一插件路径。
|
||||
// API Key 和未命中插件的账号保持各自原有的 HTTPUpstream 行为。
|
||||
func (s *AccountTestService) doOpenAIAccountTestUpstream(
|
||||
request *http.Request,
|
||||
proxyURL string,
|
||||
account *Account,
|
||||
useTLSFallback bool,
|
||||
) (*http.Response, error) {
|
||||
if s.pluginManager != nil {
|
||||
response, handled, err := s.pluginManager.RoundTripOpenAIOAuth(request.Context(), request, proxyURL, account)
|
||||
if handled {
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
if useTLSFallback {
|
||||
return s.httpUpstream.DoWithTLS(
|
||||
request,
|
||||
proxyURL,
|
||||
account.ID,
|
||||
account.Concurrency,
|
||||
s.tlsFPProfileService.ResolveTLSProfile(account),
|
||||
)
|
||||
}
|
||||
return s.httpUpstream.Do(request, proxyURL, account.ID, account.Concurrency)
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Co
|
||||
|
||||
// Client disconnected: do NOT fail over to another account and do NOT evict
|
||||
// this one — the upstream never had a chance to exhibit a fault.
|
||||
if errors.Is(err, context.Canceled) {
|
||||
if errors.Is(err, context.Canceled) || (errors.Is(err, context.DeadlineExceeded) && errors.Is(ctx.Err(), context.DeadlineExceeded)) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -129,6 +129,12 @@ func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Co
|
||||
scheduleOllamaCloudUsageActivity(s.deferredService, account)
|
||||
}
|
||||
|
||||
// 插件已把请求交给上游时,自动切换账号可能造成重复扣费或重复执行。
|
||||
var pluginErr *PluginTransportError
|
||||
if errors.As(err, &pluginErr) && pluginErr.RequestSent {
|
||||
return err
|
||||
}
|
||||
|
||||
if classifyOpenAITransportError(err).Persistent {
|
||||
s.tempUnscheduleOpenAITransportError(ctx, account, safeErr)
|
||||
}
|
||||
|
||||
@@ -112,7 +112,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
}
|
||||
|
||||
wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account)
|
||||
forceHTTPBridge := account.Platform == PlatformGrok
|
||||
forceHTTPBridge := account.Platform == PlatformGrok ||
|
||||
(s.pluginManager != nil && s.pluginManager.ShouldRouteOpenAIOAuth(account))
|
||||
modeRouterV2Enabled := s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.ModeRouterV2Enabled
|
||||
ingressMode := OpenAIWSIngressModeCtxPool
|
||||
if modeRouterV2Enabled && !forceHTTPBridge {
|
||||
|
||||
@@ -407,7 +407,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
if buildErr != nil {
|
||||
return nil, buildErr
|
||||
}
|
||||
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
if turn == 1 {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
type PluginHostInfo struct {
|
||||
Version string
|
||||
BuildType string
|
||||
}
|
||||
|
||||
func EvaluatePluginCompatibility(manifest PluginManifest, host PluginHostInfo) PluginCompatibility {
|
||||
result := PluginCompatibility{
|
||||
CurrentSub2API: host.Version,
|
||||
RequiredSub2API: manifest.Requires.Sub2API,
|
||||
RecommendedSub2API: manifest.Requires.RecommendedSub2APIVersion,
|
||||
PluginProtocol: manifest.Requires.PluginProtocol,
|
||||
TransportAPI: manifest.Requires.TransportAPI,
|
||||
UIBridge: manifest.Requires.UIBridge,
|
||||
}
|
||||
if manifest.Requires.PluginProtocol != pluginv1.ProtocolVersion ||
|
||||
manifest.Requires.TransportAPI != pluginv1.TransportAPIVersion ||
|
||||
manifest.Requires.UIBridge != pluginv1.UIBridgeVersion {
|
||||
result.Status = "incompatible"
|
||||
result.Message = "插件协议版本与当前 Sub2API 不兼容"
|
||||
return result
|
||||
}
|
||||
if !matchesSemverRange(host.Version, manifest.Requires.Sub2API) {
|
||||
result.Status = "incompatible"
|
||||
result.Message = fmt.Sprintf("当前 Sub2API %s 不满足插件要求 %s", host.Version, manifest.Requires.Sub2API)
|
||||
return result
|
||||
}
|
||||
result.Compatible = true
|
||||
for _, tested := range manifest.Requires.TestedSub2APIVersions {
|
||||
if normalizeSemver(tested) == normalizeSemver(host.Version) {
|
||||
result.Tested = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if result.Tested {
|
||||
result.Status = "compatible"
|
||||
result.Message = "当前 Sub2API 版本已由插件声明测试"
|
||||
} else {
|
||||
result.Status = "untested"
|
||||
result.Message = "版本范围兼容,但插件未声明已测试当前 Sub2API 版本"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeSemver(version string) string {
|
||||
v := strings.TrimSpace(version)
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(v, "v") {
|
||||
v = "v" + v
|
||||
}
|
||||
if !semver.IsValid(v) {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func matchesSemverRange(version, expression string) bool {
|
||||
v := normalizeSemver(version)
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
tokens := strings.Fields(strings.ReplaceAll(expression, ",", " "))
|
||||
if len(tokens) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, token := range tokens {
|
||||
op := "="
|
||||
raw := token
|
||||
for _, candidate := range []string{">=", "<=", ">", "<", "="} {
|
||||
if strings.HasPrefix(token, candidate) {
|
||||
op = candidate
|
||||
raw = strings.TrimSpace(strings.TrimPrefix(token, candidate))
|
||||
break
|
||||
}
|
||||
}
|
||||
bound := normalizeSemver(raw)
|
||||
if bound == "" {
|
||||
return false
|
||||
}
|
||||
comparison := semver.Compare(v, bound)
|
||||
matched := map[string]bool{
|
||||
">=": comparison >= 0,
|
||||
"<=": comparison <= 0,
|
||||
">": comparison > 0,
|
||||
"<": comparison < 0,
|
||||
"=": comparison == 0,
|
||||
}[op]
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEvaluatePluginCompatibility(t *testing.T) {
|
||||
manifest := testPluginManifest(nil)
|
||||
host := PluginHostInfo{Version: "0.1.179", BuildType: "release"}
|
||||
|
||||
result := EvaluatePluginCompatibility(manifest, host)
|
||||
require.True(t, result.Compatible)
|
||||
assert.True(t, result.Tested)
|
||||
assert.Equal(t, "compatible", result.Status)
|
||||
|
||||
manifest.Requires.TestedSub2APIVersions = []string{"0.1.178"}
|
||||
result = EvaluatePluginCompatibility(manifest, host)
|
||||
require.True(t, result.Compatible)
|
||||
assert.False(t, result.Tested)
|
||||
assert.Equal(t, "untested", result.Status)
|
||||
|
||||
manifest.Requires.Sub2API = ">=0.2.0 <0.3.0"
|
||||
result = EvaluatePluginCompatibility(manifest, host)
|
||||
assert.False(t, result.Compatible)
|
||||
assert.Equal(t, "incompatible", result.Status)
|
||||
}
|
||||
|
||||
func TestEvaluatePluginCompatibilityRejectsProtocolMismatch(t *testing.T) {
|
||||
manifest := testPluginManifest(nil)
|
||||
manifest.Requires.PluginProtocol = pluginv1.ProtocolVersion + 1
|
||||
|
||||
result := EvaluatePluginCompatibility(manifest, PluginHostInfo{Version: "0.1.179"})
|
||||
|
||||
assert.False(t, result.Compatible)
|
||||
assert.Equal(t, "incompatible", result.Status)
|
||||
}
|
||||
|
||||
func TestMatchesSemverRange(t *testing.T) {
|
||||
assert.True(t, matchesSemverRange("0.1.179", ">=0.1.170, <0.2.0"))
|
||||
assert.True(t, matchesSemverRange("v1.2.3", "=1.2.3"))
|
||||
assert.False(t, matchesSemverRange("0.1.169", ">=0.1.170 <0.2.0"))
|
||||
assert.False(t, matchesSemverRange("dev", ">=0.1.0"))
|
||||
assert.False(t, matchesSemverRange("0.1.179", "^0.1.0"))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type pluginRoutingHTTPUpstream struct {
|
||||
doCalls int
|
||||
doWithTLSCalls int
|
||||
}
|
||||
|
||||
func (u *pluginRoutingHTTPUpstream) Do(*http.Request, string, int64, int) (*http.Response, error) {
|
||||
u.doCalls++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("legacy")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *pluginRoutingHTTPUpstream) DoWithTLS(
|
||||
request *http.Request,
|
||||
proxyURL string,
|
||||
accountID int64,
|
||||
accountConcurrency int,
|
||||
_ *tlsfingerprint.Profile,
|
||||
) (*http.Response, error) {
|
||||
u.doWithTLSCalls++
|
||||
return u.Do(request, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
func TestPluginManagerRoutingDoesNotTouchAPIKeyOrOtherProviders(t *testing.T) {
|
||||
manager := &PluginManager{}
|
||||
request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
accounts := []*Account{
|
||||
{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
|
||||
{ID: 2, Platform: PlatformAnthropic, Type: AccountTypeOAuth},
|
||||
{ID: 3, Platform: PlatformGemini, Type: AccountTypeOAuth},
|
||||
}
|
||||
for _, account := range accounts {
|
||||
response, handled, routeErr := manager.RoundTripOpenAIOAuth(context.Background(), request, "", account)
|
||||
assert.Nil(t, response)
|
||||
assert.False(t, handled)
|
||||
assert.NoError(t, routeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginManagerRoutingKeepsOAuthOnLegacyPathWithoutEnabledBinding(t *testing.T) {
|
||||
manager := &PluginManager{}
|
||||
request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
account := &Account{ID: 10, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
|
||||
response, handled, routeErr := manager.RoundTripOpenAIOAuth(context.Background(), request, "", account)
|
||||
|
||||
assert.Nil(t, response)
|
||||
assert.False(t, handled)
|
||||
assert.NoError(t, routeErr)
|
||||
}
|
||||
|
||||
func TestPluginManagerRoutingSelectsOnlyEligibleOpenAIOAuthAccounts(t *testing.T) {
|
||||
manager := &PluginManager{}
|
||||
manager.route.Store(&pluginRoute{pluginID: 1, rolloutPercent: 100, unavailable: "测试不可用"})
|
||||
|
||||
assert.True(t, manager.ShouldRouteOpenAIOAuth(&Account{ID: 10, Platform: PlatformOpenAI, Type: AccountTypeOAuth}))
|
||||
assert.False(t, manager.ShouldRouteOpenAIOAuth(&Account{ID: 10, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}))
|
||||
assert.False(t, manager.ShouldRouteOpenAIOAuth(&Account{ID: 10, Platform: PlatformGrok, Type: AccountTypeOAuth}))
|
||||
assert.False(t, manager.ShouldRouteOpenAIOAuth(nil))
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayPluginRoutingPreservesAPIKeyAndFailsClosedForOAuth(t *testing.T) {
|
||||
manager := &PluginManager{}
|
||||
manager.route.Store(&pluginRoute{pluginID: 1, rolloutPercent: 100, unavailable: "测试不可用"})
|
||||
upstream := &pluginRoutingHTTPUpstream{}
|
||||
service := &OpenAIGatewayService{pluginManager: manager, httpUpstream: upstream}
|
||||
|
||||
apiKeyRequest, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
apiKeyResponse, err := service.doOpenAIUpstream(apiKeyRequest, "", &Account{
|
||||
ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, apiKeyResponse)
|
||||
_ = apiKeyResponse.Body.Close()
|
||||
assert.Equal(t, 1, upstream.doCalls)
|
||||
|
||||
oauthRequest, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
oauthResponse, err := service.doOpenAIUpstream(oauthRequest, "", &Account{
|
||||
ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, oauthResponse)
|
||||
assert.Contains(t, err.Error(), "插件不可用")
|
||||
assert.Equal(t, 1, upstream.doCalls)
|
||||
}
|
||||
|
||||
func TestStablePluginBucketIsDeterministicAndBounded(t *testing.T) {
|
||||
for id := int64(1); id <= 1000; id++ {
|
||||
first := stablePluginBucket(id)
|
||||
assert.Equal(t, first, stablePluginBucket(id))
|
||||
assert.Less(t, first, uint64(100))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
PluginCapabilityOpenAIOAuthOutbound = "openai.oauth.outbound_transport.v1"
|
||||
PluginStateDisabled = "disabled"
|
||||
PluginStateStarting = "starting"
|
||||
PluginStateEnabled = "enabled"
|
||||
PluginStateError = "error"
|
||||
PluginStateIncompatible = "incompatible"
|
||||
PluginSignatureTrusted = "trusted"
|
||||
PluginSignatureUnsigned = "unsigned"
|
||||
)
|
||||
|
||||
var pluginIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)+$`)
|
||||
|
||||
var ErrPluginStateChanged = errors.New("插件状态已在其他实例中变化,请刷新后重试")
|
||||
|
||||
// PluginManifest 是 .s2plugin 包中可在执行二进制前检查的声明。
|
||||
type PluginManifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Requires PluginRequirements `json:"requires"`
|
||||
Capabilities []PluginCapability `json:"capabilities"`
|
||||
Runtimes map[string]PluginRuntime `json:"runtimes"`
|
||||
UI PluginUIManifest `json:"ui"`
|
||||
Files map[string]string `json:"files"`
|
||||
}
|
||||
|
||||
type PluginRequirements struct {
|
||||
Sub2API string `json:"sub2api"`
|
||||
RecommendedSub2APIVersion string `json:"recommended_sub2api_version,omitempty"`
|
||||
TestedSub2APIVersions []string `json:"tested_sub2api_versions,omitempty"`
|
||||
PluginProtocol int `json:"plugin_protocol"`
|
||||
TransportAPI int `json:"transport_api"`
|
||||
UIBridge int `json:"ui_bridge"`
|
||||
}
|
||||
|
||||
type PluginCapability struct {
|
||||
ID string `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
AccountType string `json:"account_type"`
|
||||
}
|
||||
|
||||
type PluginRuntime struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type PluginUIManifest struct {
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
}
|
||||
|
||||
type PluginSignature struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
KeyID string `json:"key_id"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// PluginCompatibility 是管理页面展示和启用门禁共同使用的兼容性结论。
|
||||
type PluginCompatibility struct {
|
||||
Compatible bool `json:"compatible"`
|
||||
Tested bool `json:"tested"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
CurrentSub2API string `json:"current_sub2api_version"`
|
||||
RequiredSub2API string `json:"required_sub2api_version"`
|
||||
RecommendedSub2API string `json:"recommended_sub2api_version"`
|
||||
PluginProtocol int `json:"plugin_protocol"`
|
||||
TransportAPI int `json:"transport_api"`
|
||||
UIBridge int `json:"ui_bridge"`
|
||||
}
|
||||
|
||||
type PluginInstallation struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginKey string `json:"plugin_key"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Manifest PluginManifest `json:"manifest"`
|
||||
ArtifactData []byte `json:"-"`
|
||||
ArtifactPath string `json:"-"`
|
||||
InstallPath string `json:"-"`
|
||||
BinaryPath string `json:"-"`
|
||||
BinarySHA256 string `json:"binary_sha256"`
|
||||
SignatureStatus string `json:"signature_status"`
|
||||
State string `json:"state"`
|
||||
ConfigEncrypted string `json:"-"`
|
||||
LastError string `json:"last_error"`
|
||||
InstalledBy *int64 `json:"installed_by"`
|
||||
InstalledAt time.Time `json:"installed_at"`
|
||||
EnabledAt *time.Time `json:"enabled_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Bindings []PluginBinding `json:"bindings"`
|
||||
Compatibility PluginCompatibility `json:"compatibility"`
|
||||
RuntimeHealthy bool `json:"runtime_healthy"`
|
||||
RuntimeMessage string `json:"runtime_message"`
|
||||
}
|
||||
|
||||
type PluginBinding struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID int64 `json:"plugin_id"`
|
||||
Capability string `json:"capability"`
|
||||
Platform string `json:"platform"`
|
||||
AccountType string `json:"account_type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
RolloutPercent int `json:"rollout_percent"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PluginRepository interface {
|
||||
List(ctx context.Context) ([]*PluginInstallation, error)
|
||||
GetByID(ctx context.Context, id int64) (*PluginInstallation, error)
|
||||
GetByKey(ctx context.Context, key string) (*PluginInstallation, error)
|
||||
Install(ctx context.Context, plugin *PluginInstallation, bindings []PluginBinding) (*PluginInstallation, error)
|
||||
GetArtifact(ctx context.Context, id int64) ([]byte, error)
|
||||
Delete(ctx context.Context, id int64, expectedBinarySHA256 string) error
|
||||
BeginEnable(ctx context.Context, id int64, binarySHA256, expectedState string) error
|
||||
MarkRuntimeHealthy(ctx context.Context, id int64, binarySHA256, configEncrypted string) error
|
||||
UpdateState(ctx context.Context, id int64, state, lastError string, enabledAt *time.Time, expectedBinarySHA256, expectedState string) error
|
||||
UpdateConfig(ctx context.Context, id int64, encrypted, expectedBinarySHA256 string) error
|
||||
UpdateBindingsAndState(ctx context.Context, pluginID int64, bindings []PluginBinding, state, lastError string, enabledAt *time.Time, expectedState, expectedBinarySHA256 string) error
|
||||
}
|
||||
|
||||
func (m PluginManifest) RuntimeKey() string {
|
||||
return runtime.GOOS + "-" + runtime.GOARCH
|
||||
}
|
||||
|
||||
func (m PluginManifest) Validate() error {
|
||||
if m.SchemaVersion != 1 {
|
||||
return fmt.Errorf("不支持的插件清单版本: %d", m.SchemaVersion)
|
||||
}
|
||||
if !pluginIDPattern.MatchString(m.ID) || len(m.ID) > 160 {
|
||||
return errors.New("插件 ID 必须是长度不超过 160 的小写命名空间标识")
|
||||
}
|
||||
if strings.TrimSpace(m.Name) == "" || len(m.Name) > 160 {
|
||||
return errors.New("插件名称不能为空且不能超过 160 个字符")
|
||||
}
|
||||
if normalizeSemver(m.Version) == "" {
|
||||
return errors.New("插件版本必须是有效的语义化版本")
|
||||
}
|
||||
if strings.TrimSpace(m.Requires.Sub2API) == "" {
|
||||
return errors.New("插件必须声明 requires.sub2api")
|
||||
}
|
||||
if m.Requires.PluginProtocol != pluginv1.ProtocolVersion ||
|
||||
m.Requires.TransportAPI != pluginv1.TransportAPIVersion ||
|
||||
m.Requires.UIBridge != pluginv1.UIBridgeVersion {
|
||||
return errors.New("插件协议、传输 API 或 UI Bridge 版本与当前宿主不兼容")
|
||||
}
|
||||
if len(m.Capabilities) == 0 {
|
||||
return errors.New("插件必须声明至少一个能力")
|
||||
}
|
||||
for _, capability := range m.Capabilities {
|
||||
if capability.ID != PluginCapabilityOpenAIOAuthOutbound || capability.Platform != PlatformOpenAI || capability.AccountType != AccountTypeOAuth {
|
||||
return fmt.Errorf("初期仅支持能力 %s", PluginCapabilityOpenAIOAuthOutbound)
|
||||
}
|
||||
}
|
||||
runtimeEntry, ok := m.Runtimes[m.RuntimeKey()]
|
||||
if !ok || !safePluginRelativePath(runtimeEntry.Path) {
|
||||
return fmt.Errorf("插件不支持当前运行平台 %s", m.RuntimeKey())
|
||||
}
|
||||
if !safePluginRelativePath(m.UI.Entrypoint) || !strings.HasPrefix(m.UI.Entrypoint, "ui/") {
|
||||
return errors.New("插件 UI 入口必须位于 ui/ 目录")
|
||||
}
|
||||
if len(m.Files) == 0 {
|
||||
return errors.New("插件清单必须声明文件哈希")
|
||||
}
|
||||
for path, hash := range m.Files {
|
||||
if !safePluginRelativePath(path) || !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(hash) {
|
||||
return fmt.Errorf("插件文件声明无效: %s", path)
|
||||
}
|
||||
}
|
||||
if _, ok := m.Files[runtimeEntry.Path]; !ok {
|
||||
return errors.New("运行时二进制未包含在文件哈希声明中")
|
||||
}
|
||||
if _, ok := m.Files[m.UI.Entrypoint]; !ok {
|
||||
return errors.New("UI 入口未包含在文件哈希声明中")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safePluginRelativePath(path string) bool {
|
||||
cleaned := strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")
|
||||
return cleaned != "" && cleaned != "." && !strings.HasPrefix(cleaned, "/") &&
|
||||
!strings.HasPrefix(cleaned, "../") && !strings.Contains(cleaned, "/../") && cleaned == strings.TrimPrefix(cleaned, "./")
|
||||
}
|
||||
|
||||
func (m PluginManifest) MarshalJSONBytes() ([]byte, error) {
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
func (m PluginManifest) SortedCapabilities() []PluginCapability {
|
||||
out := append([]PluginCapability(nil), m.Capabilities...)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
pluginManifestFilename = "manifest.json"
|
||||
pluginSignatureFilename = "signature.json"
|
||||
pluginArchiveMaxFiles = 512
|
||||
builtInOpenAITransportPluginID = "local.sub2api.openai-transport"
|
||||
builtInOpenAITransportPublisherKeyID = "sub2api-openai-transport-v1"
|
||||
builtInOpenAITransportPublisherKeyBase64 = "MqzSXAoG0iVR5kKWrC+mqcCeExkrT6zAr2WpQ4sA+yc="
|
||||
)
|
||||
|
||||
type PluginPackageInstaller struct {
|
||||
cfg *config.Config
|
||||
hostInfo PluginHostInfo
|
||||
rootDir string
|
||||
}
|
||||
|
||||
func NewPluginPackageInstaller(cfg *config.Config, hostInfo PluginHostInfo) *PluginPackageInstaller {
|
||||
return &PluginPackageInstaller{
|
||||
cfg: cfg,
|
||||
hostInfo: hostInfo,
|
||||
rootDir: resolvePluginRootDir(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
func resolvePluginRootDir(cfg *config.Config) string {
|
||||
if cfg != nil && strings.TrimSpace(cfg.Plugins.DataDir) != "" {
|
||||
return filepath.Clean(cfg.Plugins.DataDir)
|
||||
}
|
||||
base := strings.TrimSpace(os.Getenv("DATA_DIR"))
|
||||
if base == "" {
|
||||
base = "./data"
|
||||
}
|
||||
return filepath.Join(base, "plugins")
|
||||
}
|
||||
|
||||
func (i *PluginPackageInstaller) RootDir() string {
|
||||
return i.rootDir
|
||||
}
|
||||
|
||||
func (i *PluginPackageInstaller) Install(ctx context.Context, reader io.Reader, installedBy *int64) (*PluginInstallation, error) {
|
||||
if i == nil || i.cfg == nil {
|
||||
return nil, errors.New("插件安装器未配置")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stagingDir := filepath.Join(i.rootDir, "staging")
|
||||
packagesDir := filepath.Join(i.rootDir, "packages")
|
||||
installedDir := filepath.Join(i.rootDir, "installed")
|
||||
for _, dir := range []string{stagingDir, packagesDir, installedDir} {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("创建插件目录: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp(stagingDir, "upload-*.s2plugin")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建插件上传临时文件: %w", err)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
committed := false
|
||||
defer func() {
|
||||
_ = tempFile.Close()
|
||||
if !committed {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
hasher := sha256.New()
|
||||
limit := i.cfg.Plugins.MaxUploadBytes
|
||||
written, err := io.Copy(io.MultiWriter(tempFile, hasher), io.LimitReader(reader, limit+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取插件包: %w", err)
|
||||
}
|
||||
if written > limit {
|
||||
return nil, fmt.Errorf("插件包超过 %d 字节限制", limit)
|
||||
}
|
||||
if err := tempFile.Sync(); err != nil {
|
||||
return nil, fmt.Errorf("同步插件包: %w", err)
|
||||
}
|
||||
if err := tempFile.Close(); err != nil {
|
||||
return nil, fmt.Errorf("关闭插件包: %w", err)
|
||||
}
|
||||
artifactSHA := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
archive, err := zip.OpenReader(tempPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("插件包不是有效的 ZIP: %w", err)
|
||||
}
|
||||
defer func() { _ = archive.Close() }()
|
||||
manifest, _, signatureStatus, err := i.inspectArchive(&archive.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
compatibility := EvaluatePluginCompatibility(manifest, i.hostInfo)
|
||||
initialState := PluginStateDisabled
|
||||
if !compatibility.Compatible {
|
||||
initialState = PluginStateIncompatible
|
||||
}
|
||||
|
||||
installParent := filepath.Join(installedDir, manifest.ID)
|
||||
if err := os.MkdirAll(installParent, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("创建插件安装父目录: %w", err)
|
||||
}
|
||||
extractPath, err := os.MkdirTemp(installParent, ".install-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建插件安装临时目录: %w", err)
|
||||
}
|
||||
installNonce := strings.TrimPrefix(filepath.Base(extractPath), ".install-")
|
||||
installPath := filepath.Join(installParent, manifest.Version+"-"+artifactSHA[:12]+"-"+installNonce)
|
||||
extracted := false
|
||||
defer func() {
|
||||
if !extracted {
|
||||
_ = os.RemoveAll(extractPath)
|
||||
}
|
||||
}()
|
||||
if err := i.extractArchive(ctx, &archive.Reader, manifest, extractPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Rename(extractPath, installPath); err != nil {
|
||||
return nil, fmt.Errorf("提交插件安装目录: %w", err)
|
||||
}
|
||||
extracted = true
|
||||
|
||||
artifactPath := filepath.Join(packagesDir, manifest.ID+"-"+manifest.Version+"-"+artifactSHA[:12]+"-"+installNonce+".s2plugin")
|
||||
if err := os.Rename(tempPath, artifactPath); err != nil {
|
||||
_ = os.RemoveAll(installPath)
|
||||
return nil, fmt.Errorf("保存插件包: %w", err)
|
||||
}
|
||||
committed = true
|
||||
artifactData, err := os.ReadFile(artifactPath)
|
||||
if err != nil {
|
||||
_ = os.Remove(artifactPath)
|
||||
_ = os.RemoveAll(installPath)
|
||||
return nil, fmt.Errorf("读取已保存插件包: %w", err)
|
||||
}
|
||||
runtimeEntry := manifest.Runtimes[manifest.RuntimeKey()]
|
||||
return &PluginInstallation{
|
||||
PluginKey: manifest.ID,
|
||||
Name: manifest.Name,
|
||||
Version: manifest.Version,
|
||||
Description: manifest.Description,
|
||||
Author: manifest.Author,
|
||||
Manifest: manifest,
|
||||
ArtifactData: artifactData,
|
||||
ArtifactPath: artifactPath,
|
||||
InstallPath: installPath,
|
||||
BinaryPath: filepath.Join(installPath, filepath.FromSlash(runtimeEntry.Path)),
|
||||
BinarySHA256: manifest.Files[runtimeEntry.Path],
|
||||
SignatureStatus: signatureStatus,
|
||||
State: initialState,
|
||||
InstalledBy: installedBy,
|
||||
Compatibility: compatibility,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (i *PluginPackageInstaller) inspectArchive(archive *zip.Reader) (PluginManifest, []byte, string, error) {
|
||||
if len(archive.File) == 0 || len(archive.File) > pluginArchiveMaxFiles {
|
||||
return PluginManifest{}, nil, "", errors.New("插件包文件数量无效")
|
||||
}
|
||||
entries := make(map[string]*zip.File, len(archive.File))
|
||||
var total uint64
|
||||
for _, file := range archive.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
if _, err := normalizePluginArchivePath(strings.TrimSuffix(file.Name, "/")); err != nil {
|
||||
return PluginManifest{}, nil, "", err
|
||||
}
|
||||
continue
|
||||
}
|
||||
name, err := normalizePluginArchivePath(file.Name)
|
||||
if err != nil {
|
||||
return PluginManifest{}, nil, "", err
|
||||
}
|
||||
if _, exists := entries[name]; exists {
|
||||
return PluginManifest{}, nil, "", fmt.Errorf("插件包包含重复路径: %s", name)
|
||||
}
|
||||
if file.Mode()&os.ModeSymlink != 0 {
|
||||
return PluginManifest{}, nil, "", fmt.Errorf("插件包不允许符号链接: %s", name)
|
||||
}
|
||||
total += file.UncompressedSize64
|
||||
if total > uint64(i.cfg.Plugins.MaxUncompressedBytes) {
|
||||
return PluginManifest{}, nil, "", errors.New("插件包解压后体积超过限制")
|
||||
}
|
||||
entries[name] = file
|
||||
}
|
||||
manifestFile := entries[pluginManifestFilename]
|
||||
if manifestFile == nil {
|
||||
return PluginManifest{}, nil, "", errors.New("插件包缺少 manifest.json")
|
||||
}
|
||||
manifestRaw, err := readPluginZipFile(manifestFile, 2*1024*1024)
|
||||
if err != nil {
|
||||
return PluginManifest{}, nil, "", fmt.Errorf("读取插件清单: %w", err)
|
||||
}
|
||||
var manifest PluginManifest
|
||||
decoder := json.NewDecoder(bytes.NewReader(manifestRaw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&manifest); err != nil {
|
||||
return PluginManifest{}, nil, "", fmt.Errorf("解析插件清单: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return PluginManifest{}, nil, "", errors.New("插件清单只能包含一个 JSON 对象")
|
||||
}
|
||||
if err := manifest.Validate(); err != nil {
|
||||
return PluginManifest{}, nil, "", err
|
||||
}
|
||||
for path := range entries {
|
||||
if path == pluginManifestFilename || path == pluginSignatureFilename {
|
||||
continue
|
||||
}
|
||||
if _, declared := manifest.Files[path]; !declared {
|
||||
return PluginManifest{}, nil, "", fmt.Errorf("插件包包含未声明文件: %s", path)
|
||||
}
|
||||
}
|
||||
for path := range manifest.Files {
|
||||
if entries[path] == nil {
|
||||
return PluginManifest{}, nil, "", fmt.Errorf("插件包缺少已声明文件: %s", path)
|
||||
}
|
||||
}
|
||||
signatureStatus, err := i.verifySignature(entries[pluginSignatureFilename], manifestRaw, manifest.ID)
|
||||
if err != nil {
|
||||
return PluginManifest{}, nil, "", err
|
||||
}
|
||||
return manifest, manifestRaw, signatureStatus, nil
|
||||
}
|
||||
|
||||
func (i *PluginPackageInstaller) verifySignature(file *zip.File, manifestRaw []byte, pluginID string) (string, error) {
|
||||
if file == nil {
|
||||
if i.cfg.Plugins.AllowUnsigned {
|
||||
return PluginSignatureUnsigned, nil
|
||||
}
|
||||
return "", errors.New("生产配置不允许安装未签名插件")
|
||||
}
|
||||
raw, err := readPluginZipFile(file, 64*1024)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取插件签名: %w", err)
|
||||
}
|
||||
var signature PluginSignature
|
||||
if err := json.Unmarshal(raw, &signature); err != nil {
|
||||
return "", fmt.Errorf("解析插件签名: %w", err)
|
||||
}
|
||||
if signature.Algorithm != "ed25519" || strings.TrimSpace(signature.KeyID) == "" {
|
||||
return "", errors.New("插件签名算法或密钥 ID 无效")
|
||||
}
|
||||
encodedKey := trustedPluginPublisherKey(i.cfg, signature.KeyID, pluginID)
|
||||
if encodedKey == "" {
|
||||
return "", fmt.Errorf("插件发布者密钥不受信任: %s", signature.KeyID)
|
||||
}
|
||||
publicKey, err := base64.StdEncoding.DecodeString(encodedKey)
|
||||
if err != nil || len(publicKey) != ed25519.PublicKeySize {
|
||||
return "", fmt.Errorf("受信任发布者密钥无效: %s", signature.KeyID)
|
||||
}
|
||||
signatureBytes, err := base64.StdEncoding.DecodeString(signature.Signature)
|
||||
if err != nil || !ed25519.Verify(ed25519.PublicKey(publicKey), manifestRaw, signatureBytes) {
|
||||
return "", errors.New("插件签名校验失败")
|
||||
}
|
||||
return PluginSignatureTrusted, nil
|
||||
}
|
||||
|
||||
func trustedPluginPublisherKey(cfg *config.Config, keyID, pluginID string) string {
|
||||
// 内置公钥是官方私有插件的固定信任根,不允许被部署配置覆盖。
|
||||
if keyID == builtInOpenAITransportPublisherKeyID {
|
||||
if pluginID != builtInOpenAITransportPluginID {
|
||||
return ""
|
||||
}
|
||||
return builtInOpenAITransportPublisherKeyBase64
|
||||
}
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cfg.Plugins.TrustedPublishers[keyID])
|
||||
}
|
||||
|
||||
func (i *PluginPackageInstaller) extractArchive(ctx context.Context, archive *zip.Reader, manifest PluginManifest, target string) error {
|
||||
var extractedBytes int64
|
||||
extractLimit := i.cfg.Plugins.MaxUncompressedBytes
|
||||
for path, expectedHash := range manifest.Files {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
var source *zip.File
|
||||
for _, file := range archive.File {
|
||||
if strings.ReplaceAll(file.Name, "\\", "/") == path {
|
||||
source = file
|
||||
break
|
||||
}
|
||||
}
|
||||
if source == nil {
|
||||
return fmt.Errorf("缺少插件文件: %s", path)
|
||||
}
|
||||
destination, err := safePluginJoin(target, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil {
|
||||
return fmt.Errorf("创建插件文件目录: %w", err)
|
||||
}
|
||||
input, err := source.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开插件文件 %s: %w", path, err)
|
||||
}
|
||||
hasher := sha256.New()
|
||||
mode := os.FileMode(0o600)
|
||||
if path == manifest.Runtimes[manifest.RuntimeKey()].Path {
|
||||
mode = 0o700
|
||||
}
|
||||
output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
|
||||
if err != nil {
|
||||
_ = input.Close()
|
||||
return fmt.Errorf("创建插件文件 %s: %w", path, err)
|
||||
}
|
||||
remaining := extractLimit - extractedBytes
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
copied, copyErr := io.Copy(io.MultiWriter(output, hasher), io.LimitReader(input, remaining+1))
|
||||
extractedBytes += copied
|
||||
closeOutErr := output.Close()
|
||||
closeInErr := input.Close()
|
||||
if copyErr != nil || closeOutErr != nil || closeInErr != nil {
|
||||
return fmt.Errorf("解压插件文件 %s 失败", path)
|
||||
}
|
||||
if extractedBytes > extractLimit {
|
||||
return errors.New("插件包实际解压体积超过限制")
|
||||
}
|
||||
if actual := hex.EncodeToString(hasher.Sum(nil)); actual != expectedHash {
|
||||
return fmt.Errorf("插件文件哈希不匹配: %s", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizePluginArchivePath(name string) (string, error) {
|
||||
normalized := strings.ReplaceAll(strings.TrimSpace(name), "\\", "/")
|
||||
if normalized == "" || strings.HasPrefix(normalized, "/") || strings.Contains(normalized, "\x00") {
|
||||
return "", errors.New("插件包包含无效路径")
|
||||
}
|
||||
cleaned := filepath.ToSlash(filepath.Clean(normalized))
|
||||
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || cleaned != normalized {
|
||||
return "", fmt.Errorf("插件包包含不安全路径: %s", name)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func safePluginJoin(root, relative string) (string, error) {
|
||||
destination := filepath.Join(root, filepath.FromSlash(relative))
|
||||
rel, err := filepath.Rel(root, destination)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("插件路径越界: %s", relative)
|
||||
}
|
||||
return destination, nil
|
||||
}
|
||||
|
||||
func readPluginZipFile(file *zip.File, limit int64) ([]byte, error) {
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
data, err := io.ReadAll(io.LimitReader(reader, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, errors.New("插件文件超过读取限制")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPluginPackageInstallerInstallUnsignedDevelopmentPackage(t *testing.T) {
|
||||
cfg := testPluginConfig(t.TempDir(), true)
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179", BuildType: "release"})
|
||||
archive := buildTestPluginArchive(t, nil, "")
|
||||
|
||||
installation, err := installer.Install(context.Background(), bytes.NewReader(archive), nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, PluginStateDisabled, installation.State)
|
||||
assert.Equal(t, PluginSignatureUnsigned, installation.SignatureStatus)
|
||||
assert.FileExists(t, installation.ArtifactPath)
|
||||
info, statErr := os.Stat(installation.BinaryPath)
|
||||
require.NoError(t, statErr)
|
||||
assert.NotZero(t, info.Mode()&0o100)
|
||||
assert.Contains(t, installation.InstallPath, filepath.Join("installed", "com.example.openai-transport"))
|
||||
}
|
||||
|
||||
func TestPluginPackageInstallerAllowsRepeatedIdenticalUpload(t *testing.T) {
|
||||
cfg := testPluginConfig(t.TempDir(), true)
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
|
||||
archive := buildTestPluginArchive(t, nil, "")
|
||||
|
||||
first, err := installer.Install(context.Background(), bytes.NewReader(archive), nil)
|
||||
require.NoError(t, err)
|
||||
second, err := installer.Install(context.Background(), bytes.NewReader(archive), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, first.InstallPath, second.InstallPath)
|
||||
assert.NotEqual(t, first.ArtifactPath, second.ArtifactPath)
|
||||
assert.FileExists(t, first.BinaryPath)
|
||||
assert.FileExists(t, second.BinaryPath)
|
||||
}
|
||||
|
||||
func TestPluginPackageInstallerRejectsUnsignedPackageByDefault(t *testing.T) {
|
||||
cfg := testPluginConfig(t.TempDir(), false)
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
|
||||
|
||||
_, err := installer.Install(context.Background(), bytes.NewReader(buildTestPluginArchive(t, nil, "")), nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "不允许安装未签名插件")
|
||||
}
|
||||
|
||||
func TestPluginPackageInstallerVerifiesTrustedSignature(t *testing.T) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
cfg := testPluginConfig(t.TempDir(), false)
|
||||
cfg.Plugins.TrustedPublishers["local-test"] = base64.StdEncoding.EncodeToString(publicKey)
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
|
||||
|
||||
installation, installErr := installer.Install(
|
||||
context.Background(),
|
||||
bytes.NewReader(buildTestPluginArchive(t, privateKey, "local-test")),
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, installErr)
|
||||
assert.Equal(t, PluginSignatureTrusted, installation.SignatureStatus)
|
||||
}
|
||||
|
||||
func TestBuiltInOpenAITransportPublisherDoesNotRequireConfiguration(t *testing.T) {
|
||||
cfg := testPluginConfig(t.TempDir(), false)
|
||||
cfg.Plugins.TrustedPublishers[builtInOpenAITransportPublisherKeyID] = "不能覆盖内置公钥"
|
||||
|
||||
encodedKey := trustedPluginPublisherKey(cfg, builtInOpenAITransportPublisherKeyID, builtInOpenAITransportPluginID)
|
||||
publicKey, err := base64.StdEncoding.DecodeString(encodedKey)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, publicKey, ed25519.PublicKeySize)
|
||||
assert.Equal(t, builtInOpenAITransportPublisherKeyBase64, encodedKey)
|
||||
assert.Empty(t, trustedPluginPublisherKey(cfg, builtInOpenAITransportPublisherKeyID, "com.example.other-plugin"))
|
||||
}
|
||||
|
||||
func TestPluginPackageInstallerRejectsPathTraversal(t *testing.T) {
|
||||
cfg := testPluginConfig(t.TempDir(), true)
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
|
||||
archive := buildTestPluginArchiveWithExtra(t, nil, "", "../escape", []byte("escape"))
|
||||
|
||||
_, err := installer.Install(context.Background(), bytes.NewReader(archive), nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "不安全路径")
|
||||
}
|
||||
|
||||
func TestPluginPackageInstallerKeepsHostVersionMismatchDisabled(t *testing.T) {
|
||||
cfg := testPluginConfig(t.TempDir(), true)
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.2.0"})
|
||||
|
||||
installation, err := installer.Install(context.Background(), bytes.NewReader(buildTestPluginArchive(t, nil, "")), nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, PluginStateIncompatible, installation.State)
|
||||
assert.False(t, installation.Compatibility.Compatible)
|
||||
}
|
||||
|
||||
func TestPluginPackageInstallerRejectsHashMismatch(t *testing.T) {
|
||||
cfg := testPluginConfig(t.TempDir(), true)
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
|
||||
manifest := testPluginManifest(map[string][]byte{
|
||||
"bin/plugin": []byte("binary"),
|
||||
"ui/index.html": []byte("<html></html>"),
|
||||
})
|
||||
manifest.Files["bin/plugin"] = string(bytes.Repeat([]byte("0"), 64))
|
||||
|
||||
_, err := installer.Install(context.Background(), bytes.NewReader(buildPluginArchive(t, manifest, nil, "", nil)), nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "哈希不匹配")
|
||||
}
|
||||
|
||||
func TestPluginPackageInstallerEnforcesActualExtractionLimit(t *testing.T) {
|
||||
archiveData := buildTestPluginArchive(t, nil, "")
|
||||
archive, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData)))
|
||||
require.NoError(t, err)
|
||||
cfg := testPluginConfig(t.TempDir(), true)
|
||||
cfg.Plugins.MaxUncompressedBytes = 8
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
|
||||
|
||||
err = installer.extractArchive(context.Background(), archive, testPluginManifest(nil), t.TempDir())
|
||||
|
||||
require.ErrorContains(t, err, "实际解压体积超过限制")
|
||||
}
|
||||
|
||||
func testPluginConfig(root string, allowUnsigned bool) *config.Config {
|
||||
return &config.Config{Plugins: config.PluginConfig{
|
||||
DataDir: root,
|
||||
AllowUnsigned: allowUnsigned,
|
||||
TrustedPublishers: map[string]string{},
|
||||
MaxUploadBytes: 64 * 1024 * 1024,
|
||||
MaxUncompressedBytes: 128 * 1024 * 1024,
|
||||
StartTimeoutSeconds: 5,
|
||||
}}
|
||||
}
|
||||
|
||||
func testPluginManifest(files map[string][]byte) PluginManifest {
|
||||
if files == nil {
|
||||
files = map[string][]byte{
|
||||
"bin/plugin": []byte("binary"),
|
||||
"ui/index.html": []byte("<html></html>"),
|
||||
}
|
||||
}
|
||||
hashes := make(map[string]string, len(files))
|
||||
for path, data := range files {
|
||||
digest := sha256.Sum256(data)
|
||||
hashes[path] = hex.EncodeToString(digest[:])
|
||||
}
|
||||
return PluginManifest{
|
||||
SchemaVersion: 1,
|
||||
ID: "com.example.openai-transport",
|
||||
Name: "测试 OpenAI Transport",
|
||||
Version: "0.1.0",
|
||||
Requires: PluginRequirements{
|
||||
Sub2API: ">=0.1.170 <0.2.0",
|
||||
RecommendedSub2APIVersion: "0.1.179",
|
||||
TestedSub2APIVersions: []string{"0.1.179"},
|
||||
PluginProtocol: pluginv1.ProtocolVersion,
|
||||
TransportAPI: pluginv1.TransportAPIVersion,
|
||||
UIBridge: pluginv1.UIBridgeVersion,
|
||||
},
|
||||
Capabilities: []PluginCapability{{
|
||||
ID: PluginCapabilityOpenAIOAuthOutbound,
|
||||
Platform: PlatformOpenAI,
|
||||
AccountType: AccountTypeOAuth,
|
||||
}},
|
||||
Runtimes: map[string]PluginRuntime{
|
||||
PluginManifest{}.RuntimeKey(): {Path: "bin/plugin"},
|
||||
},
|
||||
UI: PluginUIManifest{Entrypoint: "ui/index.html"},
|
||||
Files: hashes,
|
||||
}
|
||||
}
|
||||
|
||||
func buildTestPluginArchive(t *testing.T, privateKey ed25519.PrivateKey, keyID string) []byte {
|
||||
return buildPluginArchive(t, testPluginManifest(nil), privateKey, keyID, nil)
|
||||
}
|
||||
|
||||
func buildTestPluginArchiveWithExtra(t *testing.T, privateKey ed25519.PrivateKey, keyID, path string, data []byte) []byte {
|
||||
return buildPluginArchive(t, testPluginManifest(nil), privateKey, keyID, map[string][]byte{path: data})
|
||||
}
|
||||
|
||||
func buildPluginArchive(
|
||||
t *testing.T,
|
||||
manifest PluginManifest,
|
||||
privateKey ed25519.PrivateKey,
|
||||
keyID string,
|
||||
extra map[string][]byte,
|
||||
) []byte {
|
||||
t.Helper()
|
||||
manifestRaw, err := json.Marshal(manifest)
|
||||
require.NoError(t, err)
|
||||
var buffer bytes.Buffer
|
||||
writer := zip.NewWriter(&buffer)
|
||||
writeZipEntry(t, writer, pluginManifestFilename, manifestRaw)
|
||||
for path := range manifest.Files {
|
||||
data := []byte("binary")
|
||||
if path == "ui/index.html" {
|
||||
data = []byte("<html></html>")
|
||||
}
|
||||
writeZipEntry(t, writer, path, data)
|
||||
}
|
||||
for path, data := range extra {
|
||||
writeZipEntry(t, writer, path, data)
|
||||
}
|
||||
if len(privateKey) > 0 {
|
||||
signatureRaw, marshalErr := json.Marshal(PluginSignature{
|
||||
Algorithm: "ed25519",
|
||||
KeyID: keyID,
|
||||
Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestRaw)),
|
||||
})
|
||||
require.NoError(t, marshalErr)
|
||||
writeZipEntry(t, writer, pluginSignatureFilename, signatureRaw)
|
||||
}
|
||||
require.NoError(t, writer.Close())
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func writeZipEntry(t *testing.T, writer *zip.Writer, path string, data []byte) {
|
||||
t.Helper()
|
||||
entry, err := writer.Create(path)
|
||||
require.NoError(t, err)
|
||||
_, err = entry.Write(data)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||||
hclog "github.com/hashicorp/go-hclog"
|
||||
hcplugin "github.com/hashicorp/go-plugin"
|
||||
)
|
||||
|
||||
type pluginRuntime struct {
|
||||
installation *PluginInstallation
|
||||
client *hcplugin.Client
|
||||
api pluginv1.TransportPluginClient
|
||||
inFlight atomic.Int64
|
||||
draining atomic.Bool
|
||||
done chan struct{}
|
||||
doneOnce sync.Once
|
||||
}
|
||||
|
||||
func startPluginRuntime(ctx context.Context, installation *PluginInstallation, startTimeout time.Duration, socketDir string) (*pluginRuntime, error) {
|
||||
if installation == nil {
|
||||
return nil, errors.New("插件安装记录为空")
|
||||
}
|
||||
checksum, err := hex.DecodeString(installation.BinarySHA256)
|
||||
if err != nil || len(checksum) != sha256.Size {
|
||||
return nil, errors.New("插件二进制哈希无效")
|
||||
}
|
||||
cmd := exec.CommandContext(context.WithoutCancel(ctx), installation.BinaryPath)
|
||||
client := hcplugin.NewClient(&hcplugin.ClientConfig{
|
||||
HandshakeConfig: pluginv1.HandshakeConfig,
|
||||
Plugins: pluginv1.ClientPluginMap(),
|
||||
Cmd: cmd,
|
||||
AllowedProtocols: []hcplugin.Protocol{hcplugin.ProtocolGRPC},
|
||||
StartTimeout: startTimeout,
|
||||
SecureConfig: &hcplugin.SecureConfig{
|
||||
Checksum: checksum,
|
||||
Hash: sha256.New(),
|
||||
},
|
||||
Logger: hclog.NewNullLogger(),
|
||||
SyncStdout: io.Discard,
|
||||
SyncStderr: io.Discard,
|
||||
UnixSocketConfig: &hcplugin.UnixSocketConfig{TempDir: socketDir},
|
||||
SkipHostEnv: true,
|
||||
})
|
||||
rpcClient, err := client.Client()
|
||||
if err != nil {
|
||||
client.Kill()
|
||||
return nil, fmt.Errorf("启动插件进程: %w", err)
|
||||
}
|
||||
dispensed, err := rpcClient.Dispense(pluginv1.TransportPluginName)
|
||||
if err != nil {
|
||||
client.Kill()
|
||||
return nil, fmt.Errorf("获取插件传输能力: %w", err)
|
||||
}
|
||||
api, ok := dispensed.(pluginv1.TransportPluginClient)
|
||||
if !ok {
|
||||
client.Kill()
|
||||
return nil, errors.New("插件未实现传输 gRPC 客户端")
|
||||
}
|
||||
runtime := &pluginRuntime{
|
||||
installation: installation,
|
||||
client: client,
|
||||
api: api,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
infoCtx, cancel := context.WithTimeout(ctx, startTimeout)
|
||||
defer cancel()
|
||||
info, err := api.GetInfo(infoCtx, &pluginv1.GetInfoRequest{})
|
||||
if err != nil {
|
||||
runtime.kill()
|
||||
return nil, fmt.Errorf("读取插件信息: %w", err)
|
||||
}
|
||||
if info.PluginId != installation.PluginKey || info.PluginVersion != installation.Version ||
|
||||
info.ProtocolVersion != pluginv1.ProtocolVersion || info.TransportApiVersion != pluginv1.TransportAPIVersion {
|
||||
runtime.kill()
|
||||
return nil, errors.New("插件运行时信息与已校验清单不一致")
|
||||
}
|
||||
health, err := api.Health(infoCtx, &pluginv1.HealthRequest{})
|
||||
if err != nil || !health.Healthy {
|
||||
runtime.kill()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("插件健康检查失败: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("插件不健康: %s", health.Message)
|
||||
}
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) validateAndApplyConfig(ctx context.Context, configJSON []byte) error {
|
||||
_, err := r.validateAndApplyNormalizedConfig(ctx, configJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) validateAndApplyNormalizedConfig(ctx context.Context, configJSON []byte) ([]byte, error) {
|
||||
validation, err := r.api.ValidateConfig(ctx, &pluginv1.ValidateConfigRequest{ConfigJson: configJSON})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("插件配置校验失败: %w", err)
|
||||
}
|
||||
if !validation.Valid {
|
||||
return nil, fmt.Errorf("插件配置无效: %s", validation.Message)
|
||||
}
|
||||
if len(validation.NormalizedConfigJson) > 0 {
|
||||
configJSON = validation.NormalizedConfigJson
|
||||
}
|
||||
if len(configJSON) == 0 || len(configJSON) > pluginConfigMaxBytes || !json.Valid(configJSON) {
|
||||
return nil, errors.New("插件返回的规范化配置不是有效且大小受限的 JSON")
|
||||
}
|
||||
var normalized any
|
||||
if err := json.Unmarshal(configJSON, &normalized); err != nil {
|
||||
return nil, fmt.Errorf("解析插件规范化配置: %w", err)
|
||||
}
|
||||
configJSON, err = json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化插件规范化配置: %w", err)
|
||||
}
|
||||
applied, err := r.api.ApplyConfig(ctx, &pluginv1.ApplyConfigRequest{ConfigJson: configJSON})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("应用插件配置失败: %w", err)
|
||||
}
|
||||
if !applied.Applied {
|
||||
return nil, fmt.Errorf("插件拒绝应用配置: %s", applied.Message)
|
||||
}
|
||||
return configJSON, nil
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) beginRequest() bool {
|
||||
if r == nil || r.draining.Load() {
|
||||
return false
|
||||
}
|
||||
r.inFlight.Add(1)
|
||||
if r.draining.Load() {
|
||||
r.finishRequest()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) finishRequest() {
|
||||
if r.inFlight.Add(-1) == 0 && r.draining.Load() {
|
||||
r.doneOnce.Do(func() { close(r.done) })
|
||||
}
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) drain(timeout time.Duration) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.draining.Store(true)
|
||||
if r.inFlight.Load() == 0 {
|
||||
r.doneOnce.Do(func() { close(r.done) })
|
||||
}
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-r.done:
|
||||
case <-timer.C:
|
||||
}
|
||||
r.kill()
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) kill() {
|
||||
if r != nil && r.client != nil {
|
||||
r.client.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) roundTrip(ctx context.Context, request *http.Request, proxyURL string, account *Account) (*http.Response, error) {
|
||||
if request == nil || request.URL == nil || account == nil {
|
||||
return nil, errors.New("插件出站请求参数不完整")
|
||||
}
|
||||
streamCtx, cancel := context.WithCancel(ctx)
|
||||
stream, err := r.api.Forward(streamCtx)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, normalizePluginRPCError(ctx, "创建插件转发流", err, false)
|
||||
}
|
||||
requestID := strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.FormatInt(account.ID, 36)
|
||||
if err := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_Start{Start: &pluginv1.ForwardRequestStart{
|
||||
RequestId: requestID,
|
||||
Method: request.Method,
|
||||
Url: request.URL.String(),
|
||||
Host: request.Host,
|
||||
Headers: headersToPlugin(request.Header),
|
||||
ProxyUrl: proxyURL,
|
||||
AccountId: account.ID,
|
||||
AccountConcurrency: int32(account.Concurrency),
|
||||
Platform: account.Platform,
|
||||
AccountType: account.Type,
|
||||
ContentLength: request.ContentLength,
|
||||
HasBody: request.Body != nil && request.Body != http.NoBody,
|
||||
}}}); err != nil {
|
||||
cancel()
|
||||
// gRPC Send 返回错误时无法证明服务端没有收到元数据,必须禁止自动重放。
|
||||
return nil, normalizePluginRPCError(ctx, "发送插件请求元数据", err, true)
|
||||
}
|
||||
sendErr := make(chan error, 1)
|
||||
go func() {
|
||||
err := sendPluginRequestBody(stream, request.Body)
|
||||
sendErr <- err
|
||||
if err != nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
first, err := stream.Recv()
|
||||
if err != nil {
|
||||
cancel()
|
||||
select {
|
||||
case bodyErr := <-sendErr:
|
||||
if bodyErr != nil {
|
||||
return nil, normalizePluginRPCError(ctx, "发送插件请求体", bodyErr, true)
|
||||
}
|
||||
default:
|
||||
}
|
||||
return nil, normalizePluginRPCError(ctx, "接收插件响应头", err, true)
|
||||
}
|
||||
if frameError := first.GetError(); frameError != nil {
|
||||
cancel()
|
||||
return nil, &PluginTransportError{Code: frameError.Code, Message: frameError.Message, RequestSent: frameError.RequestSent}
|
||||
}
|
||||
start := first.GetStart()
|
||||
if start == nil || start.StatusCode < 100 || start.StatusCode > 599 {
|
||||
cancel()
|
||||
return nil, &PluginTransportError{
|
||||
Code: "PLUGIN_INVALID_RESPONSE",
|
||||
Message: "插件未返回有效的 HTTP 响应头",
|
||||
RequestSent: true,
|
||||
}
|
||||
}
|
||||
pipeReader, pipeWriter := io.Pipe()
|
||||
body := &pluginResponseBody{
|
||||
reader: pipeReader,
|
||||
cancel: cancel,
|
||||
done: r.finishRequest,
|
||||
}
|
||||
go receivePluginResponseBody(stream, pipeWriter, sendErr)
|
||||
return &http.Response{
|
||||
Status: start.Status,
|
||||
StatusCode: int(start.StatusCode),
|
||||
Proto: start.Protocol,
|
||||
ProtoMajor: int(start.ProtocolMajor),
|
||||
ProtoMinor: int(start.ProtocolMinor),
|
||||
Header: headersFromPlugin(start.Headers),
|
||||
Body: body,
|
||||
ContentLength: start.ContentLength,
|
||||
Request: request,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type PluginTransportError struct {
|
||||
Code string
|
||||
Message string
|
||||
RequestSent bool
|
||||
}
|
||||
|
||||
func (e *PluginTransportError) Error() string {
|
||||
if e == nil {
|
||||
return "插件传输失败"
|
||||
}
|
||||
code := strings.Map(func(value rune) rune {
|
||||
if value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' || value == '_' || value == '-' || value == '.' {
|
||||
return value
|
||||
}
|
||||
return -1
|
||||
}, e.Code)
|
||||
if len(code) > 64 {
|
||||
code = code[:64]
|
||||
}
|
||||
return fmt.Sprintf("插件传输失败 [%s]: %s", code, sanitizeUpstreamErrorMessage(e.Message))
|
||||
}
|
||||
|
||||
func normalizePluginRPCError(ctx context.Context, operation string, err error, requestMayHaveBeenSent bool) error {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return &PluginTransportError{
|
||||
Code: "PLUGIN_RPC_ERROR",
|
||||
Message: fmt.Sprintf("%s: %v", operation, err),
|
||||
RequestSent: requestMayHaveBeenSent,
|
||||
}
|
||||
}
|
||||
|
||||
func sendPluginRequestBody(stream pluginv1.TransportPlugin_ForwardClient, body io.ReadCloser) error {
|
||||
if body != nil {
|
||||
defer func() { _ = body.Close() }()
|
||||
buffer := make([]byte, 32*1024)
|
||||
for {
|
||||
read, err := body.Read(buffer)
|
||||
if read > 0 {
|
||||
chunk := append([]byte(nil), buffer[:read]...)
|
||||
if sendErr := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_BodyChunk{BodyChunk: chunk}}); sendErr != nil {
|
||||
return sendErr
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_BodyEnd{BodyEnd: true}}); err != nil {
|
||||
return err
|
||||
}
|
||||
return stream.CloseSend()
|
||||
}
|
||||
|
||||
func receivePluginResponseBody(stream pluginv1.TransportPlugin_ForwardClient, writer *io.PipeWriter, sendErr <-chan error) {
|
||||
defer func() { _ = writer.Close() }()
|
||||
for {
|
||||
frame, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
_ = writer.CloseWithError(normalizePluginRPCError(stream.Context(), "接收插件响应体", err, true))
|
||||
return
|
||||
}
|
||||
if chunk := frame.GetBodyChunk(); len(chunk) > 0 {
|
||||
if _, err := writer.Write(chunk); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if frame.GetEnd() != nil {
|
||||
select {
|
||||
case err := <-sendErr:
|
||||
if err != nil {
|
||||
_ = writer.CloseWithError(normalizePluginRPCError(stream.Context(), "发送插件请求体", err, true))
|
||||
}
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
if frameError := frame.GetError(); frameError != nil {
|
||||
_ = writer.CloseWithError(&PluginTransportError{Code: frameError.Code, Message: frameError.Message, RequestSent: frameError.RequestSent})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type pluginResponseBody struct {
|
||||
reader *io.PipeReader
|
||||
cancel context.CancelFunc
|
||||
done func()
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (b *pluginResponseBody) Read(data []byte) (int, error) {
|
||||
return b.reader.Read(data)
|
||||
}
|
||||
|
||||
func (b *pluginResponseBody) Close() error {
|
||||
var err error
|
||||
b.once.Do(func() {
|
||||
b.cancel()
|
||||
err = b.reader.Close()
|
||||
b.done()
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func headersToPlugin(headers http.Header) map[string]*pluginv1.HeaderValues {
|
||||
out := make(map[string]*pluginv1.HeaderValues, len(headers))
|
||||
for key, values := range headers {
|
||||
out[key] = &pluginv1.HeaderValues{Values: append([]string(nil), values...)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func headersFromPlugin(headers map[string]*pluginv1.HeaderValues) http.Header {
|
||||
out := make(http.Header, len(headers))
|
||||
for key, values := range headers {
|
||||
if values != nil {
|
||||
out[key] = append([]string(nil), values.Values...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type pluginFailingRequestBody struct{}
|
||||
|
||||
func (pluginFailingRequestBody) Read([]byte) (int, error) {
|
||||
return 0, errors.New("测试请求体读取失败")
|
||||
}
|
||||
func (pluginFailingRequestBody) Close() error { return nil }
|
||||
|
||||
func TestPluginRuntimeIntegration(t *testing.T) {
|
||||
packagePath := os.Getenv("SUB2API_TEST_PLUGIN_PACKAGE")
|
||||
if packagePath == "" {
|
||||
t.Skip("未提供 SUB2API_TEST_PLUGIN_PACKAGE,跳过本地插件进程集成测试")
|
||||
}
|
||||
packageFile, err := os.Open(packagePath)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = packageFile.Close() }()
|
||||
|
||||
root := t.TempDir()
|
||||
cfg := testPluginConfig(root, false)
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179", BuildType: "release"})
|
||||
installation, err := installer.Install(context.Background(), packageFile, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, PluginSignatureTrusted, installation.SignatureStatus)
|
||||
for _, relative := range []string{
|
||||
"ui/index.html",
|
||||
"ui/assets/styles.css",
|
||||
"ui/assets/bridge-v1.js",
|
||||
"ui/assets/app.js",
|
||||
} {
|
||||
assert.FileExists(t, filepath.Join(installation.InstallPath, filepath.FromSlash(relative)))
|
||||
}
|
||||
|
||||
runtime, err := startPluginRuntime(context.Background(), installation, 10*time.Second, filepath.Join(root, "runtime"))
|
||||
require.NoError(t, err)
|
||||
defer runtime.kill()
|
||||
require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{
|
||||
"request_timeout_seconds":30,
|
||||
"response_header_timeout_seconds":10,
|
||||
"idle_connection_timeout_seconds":30,
|
||||
"max_idle_connections":10,
|
||||
"max_idle_connections_per_host":5,
|
||||
"enable_http2":true,
|
||||
"tls_min_version":"1.2",
|
||||
"proxy_mode":"disabled",
|
||||
"extra_headers":{"X-Plugin-Test":"enabled"}
|
||||
}`)))
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
body, readErr := io.ReadAll(request.Body)
|
||||
require.NoError(t, readErr)
|
||||
assert.Equal(t, "payload", string(body))
|
||||
assert.Equal(t, "enabled", request.Header.Get("X-Plugin-Test"))
|
||||
assert.Equal(t, int64(len("payload")), request.ContentLength)
|
||||
assert.Empty(t, request.TransferEncoding)
|
||||
writer.Header().Set("X-Upstream", "plugin-runtime")
|
||||
writer.WriteHeader(http.StatusCreated)
|
||||
_, _ = writer.Write([]byte("response-through-plugin"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, upstream.URL, bytes.NewBufferString("payload"))
|
||||
require.NoError(t, err)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1}
|
||||
require.True(t, runtime.beginRequest())
|
||||
response, err := runtime.roundTrip(context.Background(), request, "", account)
|
||||
if err != nil {
|
||||
runtime.finishRequest()
|
||||
}
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(response.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusCreated, response.StatusCode)
|
||||
assert.Equal(t, "plugin-runtime", response.Header.Get("X-Upstream"))
|
||||
assert.Equal(t, "response-through-plugin", string(body))
|
||||
assert.Equal(t, int64(len("response-through-plugin")), response.ContentLength)
|
||||
require.NoError(t, response.Body.Close())
|
||||
|
||||
failingRequest, err := http.NewRequestWithContext(context.Background(), http.MethodPost, upstream.URL, pluginFailingRequestBody{})
|
||||
require.NoError(t, err)
|
||||
failingRequest.ContentLength = -1
|
||||
require.True(t, runtime.beginRequest())
|
||||
started := time.Now()
|
||||
_, err = runtime.roundTrip(context.Background(), failingRequest, "", account)
|
||||
runtime.finishRequest()
|
||||
require.Error(t, err)
|
||||
assert.Less(t, time.Since(started), 2*time.Second)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type pluginTokenRepository struct {
|
||||
PluginRepository
|
||||
installation *PluginInstallation
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (r *pluginTokenRepository) List(context.Context) ([]*PluginInstallation, error) {
|
||||
if r.listErr != nil {
|
||||
return nil, r.listErr
|
||||
}
|
||||
if r.installation == nil {
|
||||
return nil, nil
|
||||
}
|
||||
copy := *r.installation
|
||||
return []*PluginInstallation{©}, nil
|
||||
}
|
||||
|
||||
func (r *pluginTokenRepository) GetByID(context.Context, int64) (*PluginInstallation, error) {
|
||||
if r.installation == nil {
|
||||
return nil, errors.New("插件不存在")
|
||||
}
|
||||
copy := *r.installation
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
type pluginTokenEncryptor struct{}
|
||||
|
||||
func (pluginTokenEncryptor) Encrypt(plaintext string) (string, error) {
|
||||
return "ENC:" + plaintext, nil
|
||||
}
|
||||
|
||||
func (pluginTokenEncryptor) Decrypt(ciphertext string) (string, error) {
|
||||
if !strings.HasPrefix(ciphertext, "ENC:") {
|
||||
return "", errors.New("密文无效")
|
||||
}
|
||||
return strings.TrimPrefix(ciphertext, "ENC:"), nil
|
||||
}
|
||||
|
||||
func TestPluginUIAssetTokenCanBeResolvedByAnotherInstance(t *testing.T) {
|
||||
repo := &pluginTokenRepository{installation: &PluginInstallation{ID: 42}}
|
||||
first := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}}
|
||||
second := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}}
|
||||
|
||||
token, expires, err := first.CreateUIAssetToken(context.Background(), 42, 30*time.Minute)
|
||||
require.NoError(t, err)
|
||||
require.WithinDuration(t, time.Now().Add(30*time.Minute), expires, time.Second)
|
||||
|
||||
pluginID, err := second.ResolveUIAssetToken(token)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(42), pluginID)
|
||||
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(token)
|
||||
require.NoError(t, err)
|
||||
decoded[len(decoded)-1] ^= 1
|
||||
_, err = second.ResolveUIAssetToken(base64.RawURLEncoding.EncodeToString(decoded))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPluginUIAssetTokenRejectsOtherEncryptedPayloads(t *testing.T) {
|
||||
repo := &pluginTokenRepository{installation: &PluginInstallation{ID: 42}}
|
||||
manager := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}}
|
||||
|
||||
encrypted, err := manager.encryptor.Encrypt(`{"version":1,"plugin_id":42,"expires":4102444800}`)
|
||||
require.NoError(t, err)
|
||||
token := base64.RawURLEncoding.EncodeToString([]byte(encrypted))
|
||||
|
||||
_, err = manager.ResolveUIAssetToken(token)
|
||||
require.ErrorContains(t, err, "会话无效")
|
||||
}
|
||||
|
||||
func TestPluginReconcileFailsClosedWhenDesiredStateCannotBeRead(t *testing.T) {
|
||||
manager := &PluginManager{
|
||||
repo: &pluginTokenRepository{listErr: errors.New("数据库不可用")},
|
||||
runtimes: make(map[int64]*pluginRuntime),
|
||||
localInstallations: make(map[int64]*PluginInstallation),
|
||||
}
|
||||
|
||||
err := manager.reconcileOnce(context.Background())
|
||||
require.ErrorContains(t, err, "读取插件启用状态")
|
||||
require.True(t, manager.ShouldRouteOpenAIOAuth(&Account{
|
||||
ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth,
|
||||
}))
|
||||
|
||||
request, requestErr := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil)
|
||||
require.NoError(t, requestErr)
|
||||
_, handled, routeErr := manager.RoundTripOpenAIOAuth(context.Background(), request, "", &Account{
|
||||
ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth,
|
||||
})
|
||||
require.True(t, handled)
|
||||
require.ErrorContains(t, routeErr, "插件不可用")
|
||||
}
|
||||
|
||||
type normalizingPluginClient struct {
|
||||
pluginv1.TransportPluginClient
|
||||
normalized []byte
|
||||
applied []byte
|
||||
}
|
||||
|
||||
type pluginConfigRepository struct {
|
||||
PluginRepository
|
||||
installation *PluginInstallation
|
||||
encrypted string
|
||||
}
|
||||
|
||||
func (r *pluginConfigRepository) GetByID(context.Context, int64) (*PluginInstallation, error) {
|
||||
copy := *r.installation
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (r *pluginConfigRepository) UpdateConfig(_ context.Context, _ int64, encrypted, expectedBinarySHA256 string) error {
|
||||
if expectedBinarySHA256 != r.installation.BinarySHA256 {
|
||||
return ErrPluginStateChanged
|
||||
}
|
||||
r.encrypted = encrypted
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *normalizingPluginClient) ValidateConfig(context.Context, *pluginv1.ValidateConfigRequest, ...grpc.CallOption) (*pluginv1.ValidateConfigResponse, error) {
|
||||
return &pluginv1.ValidateConfigResponse{Valid: true, NormalizedConfigJson: c.normalized}, nil
|
||||
}
|
||||
|
||||
func (c *normalizingPluginClient) ApplyConfig(_ context.Context, request *pluginv1.ApplyConfigRequest, _ ...grpc.CallOption) (*pluginv1.ApplyConfigResponse, error) {
|
||||
c.applied = append([]byte(nil), request.ConfigJson...)
|
||||
return &pluginv1.ApplyConfigResponse{Applied: true}, nil
|
||||
}
|
||||
|
||||
func TestPluginRuntimeReturnsAndAppliesNormalizedConfig(t *testing.T) {
|
||||
client := &normalizingPluginClient{normalized: []byte(`{"z":2,"a":1}`)}
|
||||
runtime := &pluginRuntime{api: client}
|
||||
|
||||
normalized, err := runtime.validateAndApplyNormalizedConfig(context.Background(), []byte(`{"input":true}`))
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"a":1,"z":2}`, string(normalized))
|
||||
require.Equal(t, normalized, client.applied)
|
||||
}
|
||||
|
||||
func TestPluginRuntimeRejectsInvalidNormalizedConfig(t *testing.T) {
|
||||
client := &normalizingPluginClient{normalized: []byte(`{"broken"`)}
|
||||
runtime := &pluginRuntime{api: client}
|
||||
|
||||
_, err := runtime.validateAndApplyNormalizedConfig(context.Background(), []byte(`{}`))
|
||||
require.ErrorContains(t, err, "规范化配置")
|
||||
require.Empty(t, client.applied)
|
||||
}
|
||||
|
||||
func TestPluginManagerPersistsPluginNormalizedConfig(t *testing.T) {
|
||||
installation := &PluginInstallation{ID: 9, BinarySHA256: strings.Repeat("a", 64)}
|
||||
repo := &pluginConfigRepository{installation: installation}
|
||||
client := &normalizingPluginClient{normalized: []byte(`{"timeout":30,"enabled":true}`)}
|
||||
manager := &PluginManager{
|
||||
repo: repo, encryptor: pluginTokenEncryptor{},
|
||||
runtimes: map[int64]*pluginRuntime{9: {installation: installation, api: client}},
|
||||
}
|
||||
|
||||
saved, err := manager.SaveConfig(context.Background(), 9, []byte(`{"enabled":false}`))
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"enabled":true,"timeout":30}`, string(saved))
|
||||
plaintext, err := (pluginTokenEncryptor{}).Decrypt(repo.encrypted)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, string(saved), plaintext)
|
||||
}
|
||||
|
||||
func TestPluginRequestSentErrorDoesNotFailOver(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
account := &Account{ID: 7, Name: "oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
transportErr := &PluginTransportError{Code: "UPSTREAM_EOF", Message: "eof", RequestSent: true}
|
||||
|
||||
result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, transportErr, true)
|
||||
|
||||
require.Same(t, transportErr, result)
|
||||
var failover *UpstreamFailoverError
|
||||
require.False(t, errors.As(result, &failover))
|
||||
}
|
||||
|
||||
func TestPluginRPCAmbiguityPreventsReplayAfterMetadataDelivery(t *testing.T) {
|
||||
err := normalizePluginRPCError(context.Background(), "接收插件响应头", errors.New("连接已断开"), true)
|
||||
var transportErr *PluginTransportError
|
||||
require.ErrorAs(t, err, &transportErr)
|
||||
require.True(t, transportErr.RequestSent)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, err, true)
|
||||
require.Same(t, err, result)
|
||||
}
|
||||
|
||||
func TestPluginRPCFailureBeforeStreamCreationAllowsFailover(t *testing.T) {
|
||||
err := normalizePluginRPCError(context.Background(), "创建插件转发流", errors.New("连接失败"), false)
|
||||
var transportErr *PluginTransportError
|
||||
require.ErrorAs(t, err, &transportErr)
|
||||
require.False(t, transportErr.RequestSent)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
account := &Account{ID: 13, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, err, true)
|
||||
var failover *UpstreamFailoverError
|
||||
require.ErrorAs(t, result, &failover)
|
||||
}
|
||||
|
||||
func TestNormalizePluginRPCErrorPreservesCallerCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := normalizePluginRPCError(ctx, "接收响应", errors.New("rpc error: code = Canceled"), true)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
|
||||
func TestPluginStartingStateUsesBoundedCrashRecoveryWindow(t *testing.T) {
|
||||
manager := &PluginManager{cfg: &config.Config{Plugins: config.PluginConfig{StartTimeoutSeconds: 15}}}
|
||||
|
||||
require.False(t, manager.startingStateExpired(&PluginInstallation{UpdatedAt: time.Now().Add(-30 * time.Second)}))
|
||||
require.True(t, manager.startingStateExpired(&PluginInstallation{UpdatedAt: time.Now().Add(-2 * time.Minute)}))
|
||||
}
|
||||
@@ -201,9 +201,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
||||
SettingKeyAvailableChannelsEnabled: "false",
|
||||
|
||||
// Model plaza feature (default disabled; opt-in, public unless require_auth)
|
||||
SettingKeyModelPlazaEnabled: "false",
|
||||
SettingKeyModelPlazaRequireAuth: "false",
|
||||
SettingKeyModelPlazaDescription: "",
|
||||
SettingKeyModelPlazaEnabled: "false",
|
||||
SettingKeyModelPlazaRequireAuth: "false",
|
||||
SettingKeyModelPlazaDescription: "",
|
||||
SettingKeyPluginManagementEnabled: "false",
|
||||
|
||||
// Affiliate (邀请返利) feature (default disabled; opt-in)
|
||||
SettingKeyAffiliateEnabled: "false",
|
||||
@@ -820,6 +821,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
result.ModelPlazaEnabled = settings[SettingKeyModelPlazaEnabled] == "true"
|
||||
result.ModelPlazaRequireAuth = settings[SettingKeyModelPlazaRequireAuth] == "true"
|
||||
result.ModelPlazaDescription = settings[SettingKeyModelPlazaDescription]
|
||||
result.PluginManagementEnabled = settings[SettingKeyPluginManagementEnabled] == "true"
|
||||
|
||||
// Affiliate (邀请返利) feature (default: disabled; strict true)
|
||||
result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true"
|
||||
|
||||
@@ -235,6 +235,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
SettingKeyAvailableChannelsEnabled,
|
||||
SettingKeyModelPlazaEnabled,
|
||||
SettingKeyModelPlazaRequireAuth,
|
||||
SettingKeyPluginManagementEnabled,
|
||||
SettingKeyAffiliateEnabled,
|
||||
SettingKeyRiskControlEnabled,
|
||||
SettingKeyAllowUserViewErrorRequests,
|
||||
@@ -360,8 +361,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
|
||||
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
|
||||
|
||||
ModelPlazaEnabled: settings[SettingKeyModelPlazaEnabled] == "true",
|
||||
ModelPlazaRequireAuth: settings[SettingKeyModelPlazaRequireAuth] == "true",
|
||||
ModelPlazaEnabled: settings[SettingKeyModelPlazaEnabled] == "true",
|
||||
ModelPlazaRequireAuth: settings[SettingKeyModelPlazaRequireAuth] == "true",
|
||||
PluginManagementEnabled: settings[SettingKeyPluginManagementEnabled] == "true",
|
||||
|
||||
AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
|
||||
|
||||
@@ -620,6 +622,7 @@ type PublicSettingsInjectionPayload struct {
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||
RiskControlEnabled bool `json:"risk_control_enabled"`
|
||||
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
|
||||
@@ -700,6 +703,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
PluginManagementEnabled: settings.PluginManagementEnabled,
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
RiskControlEnabled: settings.RiskControlEnabled,
|
||||
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
|
||||
|
||||
@@ -435,6 +435,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
||||
updates[SettingKeyModelPlazaEnabled] = strconv.FormatBool(settings.ModelPlazaEnabled)
|
||||
updates[SettingKeyModelPlazaRequireAuth] = strconv.FormatBool(settings.ModelPlazaRequireAuth)
|
||||
updates[SettingKeyModelPlazaDescription] = settings.ModelPlazaDescription
|
||||
updates[SettingKeyPluginManagementEnabled] = strconv.FormatBool(settings.PluginManagementEnabled)
|
||||
|
||||
// Affiliate (邀请返利) feature switch
|
||||
updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled)
|
||||
|
||||
@@ -212,9 +212,10 @@ type SystemSettings struct {
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
// Model Plaza feature (public group/model pricing showcase)
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaDescription string `json:"model_plaza_description"`
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaDescription string `json:"model_plaza_description"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
|
||||
// Claude Code version check
|
||||
MinClaudeCodeVersion string
|
||||
@@ -390,8 +391,9 @@ type PublicSettings struct {
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
// Model Plaza feature (public group/model pricing showcase)
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
|
||||
// Affiliate (邀请返利) feature toggle
|
||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||
|
||||
@@ -231,6 +231,7 @@ func ProvideAccountTestService(
|
||||
tlsFPProfileService *TLSFingerprintProfileService,
|
||||
openAIGatewayService *OpenAIGatewayService,
|
||||
settingService *SettingService,
|
||||
pluginManager *PluginManager,
|
||||
) *AccountTestService {
|
||||
service := NewAccountTestService(
|
||||
accountRepo,
|
||||
@@ -244,6 +245,7 @@ func ProvideAccountTestService(
|
||||
)
|
||||
service.agentIdentityWS = openAIGatewayService
|
||||
service.SetSettingService(settingService)
|
||||
service.SetPluginManager(pluginManager)
|
||||
return service
|
||||
}
|
||||
|
||||
@@ -895,6 +897,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewTotpService,
|
||||
NewErrorPassthroughService,
|
||||
NewTLSFingerprintProfileService,
|
||||
NewPluginManager,
|
||||
NewDigestSessionStore,
|
||||
ProvideIdempotencyCoordinator,
|
||||
ProvideSystemOperationLockService,
|
||||
|
||||
Reference in New Issue
Block a user