feat: add OAuth outbound transport plugin system

This commit is contained in:
shaw
2026-08-24 09:03:37 +08:00
parent d45135d87d
commit 40ea3aebad
98 changed files with 7681 additions and 141 deletions
+30 -1
View File
@@ -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,
+7 -5
View File
@@ -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"`
+1
View File
@@ -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
+3 -2
View File
@@ -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,
+5
View File
@@ -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,
+335
View File
@@ -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))
}
+1
View File
@@ -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
+22
View File
@@ -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
+1 -1
View File
@@ -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())
+1 -1
View File
@@ -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))
}
}
+214
View File
@@ -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
}
+387
View File
@@ -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)
}
+394
View File
@@ -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{&copy}, nil
}
func (r *pluginTokenRepository) GetByID(context.Context, int64) (*PluginInstallation, error) {
if r.installation == nil {
return nil, errors.New("插件不存在")
}
copy := *r.installation
return &copy, 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 &copy, 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)}))
}
+5 -3
View File
@@ -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"
+6 -2
View File
@@ -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)
+7 -5
View File
@@ -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"`
+3
View File
@@ -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,