Merge pull request #6127 from Wei-Shaw/feat/oauth-transport-plugin-system

feat: add OAuth outbound transport plugin system
This commit is contained in:
Wesley Liddick
2026-08-24 10:16:05 +08:00
committed by GitHub
99 changed files with 7795 additions and 141 deletions
+3
View File
@@ -49,6 +49,9 @@ coverage/
.env.*
!.env.example
# 本地闭源插件目录可能包含发布签名私钥,绝不能进入 Docker 构建上下文。
/plugins/
# Local config
config.yaml
config.local.yaml
+11 -1
View File
@@ -1,4 +1,5 @@
docs/claude-relay-service/
# 本地杂项文档、测试数据和外部项目副本
/docs-local/
.codex
# ===================
@@ -136,8 +137,15 @@ docs/*
!docs/PAYMENT_CN.md
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
!docs/ASYNC_IMAGE_TASKS.md
!docs/BATCH_IMAGE_MVP.md
!docs/COMPOSITE_GROUPS.md
!docs/PLUGIN_DEVELOPMENT.md
!docs/channel-monitor-v2-safe-defaults.md
!docs/legal/
!docs/legal/*.md
!docs/screenshots/
docs/screenshots/*
!docs/screenshots/mobile-account-actions-menu.png
.serena/
.codex/
frontend/coverage/
@@ -147,3 +155,5 @@ output/
# Vitest / Vite cache at repo root
.vite/
# 本地闭源插件开发目录及构建产物不进入 Sub2API 仓库
/plugins/
+5
View File
@@ -153,6 +153,11 @@ func runMainServer() {
log.Fatalf("Failed to initialize application: %v", err)
}
defer app.Cleanup()
if app.PluginManager != nil {
if err := app.PluginManager.Start(context.Background()); err != nil {
log.Printf("Plugin manager started in degraded state: %v", err)
}
}
if app.PromptAudit != nil {
if err := app.PromptAudit.Start(context.Background()); err != nil {
// Startup continues so unrelated APIs stay up. Fail-closed (unavailable)
+26 -10
View File
@@ -25,9 +25,10 @@ import (
)
type Application struct {
Server *http.Server
PromptAudit *securityaudit.PromptService
Cleanup func()
Server *http.Server
PromptAudit *securityaudit.PromptService
PluginManager *service.PluginManager
Cleanup func()
}
func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
@@ -51,12 +52,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// BuildInfo provider
provideServiceBuildInfo,
providePluginHostInfo,
// Cleanup function provider
provideCleanup,
// Application struct
wire.Struct(new(Application), "Server", "PromptAudit", "Cleanup"),
wire.Struct(new(Application), "Server", "PromptAudit", "PluginManager", "Cleanup"),
)
return nil, nil
}
@@ -72,6 +74,13 @@ func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo {
}
}
func providePluginHostInfo(buildInfo handler.BuildInfo) service.PluginHostInfo {
return service.PluginHostInfo{
Version: buildInfo.Version,
BuildType: buildInfo.BuildType,
}
}
func provideCleanup(
entClient *ent.Client,
rdb *redis.Client,
@@ -117,6 +126,7 @@ func provideCleanup(
ollamaCloudUsage *service.OllamaCloudUsageService,
auditLog *service.AuditLogService,
promptAudit *securityaudit.PromptService,
pluginManager *service.PluginManager,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -129,6 +139,12 @@ func provideCleanup(
// 应用层清理步骤可并行执行,基础设施资源(Redis/Ent)最后按顺序关闭。
parallelSteps := []cleanupStep{
{"PluginManager", func() error {
if pluginManager != nil {
pluginManager.Stop()
}
return nil
}},
{"OpsIngressRejectAggregator", func() error {
if opsIngressReject != nil {
opsIngressReject.Stop()
@@ -328,12 +344,12 @@ func provideCleanup(
return nil
}},
{"ChannelMonitorV2Aggregator", func() error {
if channelMonitorV2Aggregator != nil {
channelMonitorV2Aggregator.Stop()
}
return nil
}},
{"ChannelMonitorRunner", func() error {
if channelMonitorV2Aggregator != nil {
channelMonitorV2Aggregator.Stop()
}
return nil
}},
{"ChannelMonitorRunner", func() error {
if channelMonitorRunner != nil {
channelMonitorRunner.Stop()
}
+30 -10
View File
@@ -200,7 +200,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService)
usageCache := service.NewUsageCache()
accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService)
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService)
pluginRepository := repository.NewPluginRepository(db)
pluginHostInfo := providePluginHostInfo(buildInfo)
pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo)
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
@@ -251,6 +254,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
errorPassthroughService := service.NewErrorPassthroughService(errorPassthroughRepository, errorPassthroughCache)
errorPassthroughHandler := admin.NewErrorPassthroughHandler(errorPassthroughService)
tlsFingerprintProfileHandler := admin.NewTLSFingerprintProfileHandler(tlsFingerprintProfileService)
pluginHandler := admin.NewPluginHandler(pluginManager)
adminAPIKeyHandler := admin.NewAdminAPIKeyHandler(adminService)
scheduledTestPlanRepository := repository.NewScheduledTestPlanRepository(db)
scheduledTestResultRepository := repository.NewScheduledTestResultRepository(db)
@@ -280,14 +284,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService)
coordinator := securityaudit.NewCoordinator(legacyEngine, promptService)
gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator)
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator)
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
totpHandler := handler.NewTotpHandler(totpService)
passkeyRepository := repository.NewPasskeyRepository(db)
@@ -341,11 +345,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher)
channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService)
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService, pluginManager)
application := &Application{
Server: httpServer,
PromptAudit: promptService,
Cleanup: v,
Server: httpServer,
PromptAudit: promptService,
PluginManager: pluginManager,
Cleanup: v,
}
return application, nil
}
@@ -353,9 +358,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// wire.go:
type Application struct {
Server *http.Server
PromptAudit *securityaudit.PromptService
Cleanup func()
Server *http.Server
PromptAudit *securityaudit.PromptService
PluginManager *service.PluginManager
Cleanup func()
}
func providePrivacyClientFactory() service.PrivacyClientFactory {
@@ -369,6 +375,13 @@ func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo {
}
}
func providePluginHostInfo(buildInfo handler.BuildInfo) service.PluginHostInfo {
return service.PluginHostInfo{
Version: buildInfo.Version,
BuildType: buildInfo.BuildType,
}
}
func provideCleanup(
entClient *ent.Client,
rdb *redis.Client,
@@ -414,6 +427,7 @@ func provideCleanup(
ollamaCloudUsage *service.OllamaCloudUsageService,
auditLog *service.AuditLogService,
promptAudit *securityaudit.PromptService,
pluginManager *service.PluginManager,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -425,6 +439,12 @@ func provideCleanup(
}
parallelSteps := []cleanupStep{
{"PluginManager", func() error {
if pluginManager != nil {
pluginManager.Stop()
}
return nil
}},
{"OpsIngressRejectAggregator", func() error {
if opsIngressReject != nil {
opsIngressReject.Stop()
+1
View File
@@ -95,6 +95,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // ollamaCloudUsage
nil, // auditLog
nil, // promptAudit
nil, // pluginManager
)
require.NotPanics(t, func() {
+11 -6
View File
@@ -24,6 +24,8 @@ require (
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/go-plugin v1.8.0
github.com/imroc/req/v3 v3.59.0
github.com/klauspost/compress v1.18.2
github.com/lib/pq v1.10.9
@@ -54,6 +56,8 @@ require (
golang.org/x/net v0.56.0
golang.org/x/sync v0.21.0
golang.org/x/term v0.44.0
google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.44.3
@@ -121,12 +125,14 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/icholy/digest v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
@@ -149,6 +155,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
@@ -187,10 +194,9 @@ require (
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/sdk v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
@@ -200,8 +206,7 @@ require (
golang.org/x/text v0.39.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/grpc v1.75.1 // indirect
google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
+45 -26
View File
@@ -120,6 +120,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
@@ -176,6 +178,7 @@ github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -232,6 +235,8 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -250,8 +255,6 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
@@ -262,6 +265,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs=
github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
@@ -269,6 +276,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo=
github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
github.com/imroc/req/v3 v3.59.0 h1:PqKhJHyBmJYob47LVuTHwRZE00ZO6icbLHe5Zra13jo=
@@ -281,6 +290,8 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
@@ -307,13 +318,15 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
@@ -350,8 +363,8 @@ github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7P
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -386,8 +399,6 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -423,8 +434,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
@@ -439,6 +448,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -507,18 +517,20 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
@@ -608,6 +620,8 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -615,6 +629,9 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -678,29 +695,31 @@ golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+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
}
response.Created(c, 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'; navigate-to 'none'")
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,
@@ -994,6 +994,7 @@ func TestAPIContracts(t *testing.T) {
"model_plaza_enabled": false,
"model_plaza_require_auth": false,
"model_plaza_description": "",
"plugin_management_enabled": false,
"risk_control_enabled": false,
"cyber_session_block_enabled": false,
"cyber_session_block_ttl_seconds": 3600,
@@ -1305,6 +1306,7 @@ func TestAPIContracts(t *testing.T) {
"model_plaza_enabled": false,
"model_plaza_require_auth": false,
"model_plaza_description": "",
"plugin_management_enabled": false,
"risk_control_enabled": false,
"cyber_session_block_enabled": false,
"cyber_session_block_ttl_seconds": 3600,
@@ -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)
}
+418
View File
@@ -0,0 +1,418 @@
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)
}
if normalized == nil {
return nil, errors.New("插件返回的规范化配置根节点必须是对象")
}
if _, ok := normalized.(map[string]any); !ok {
return nil, errors.New("插件返回的规范化配置根节点必须是对象")
}
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) checkHealth(ctx context.Context) error {
if r == nil || r.api == nil || r.client == nil || r.client.Exited() {
return errors.New("插件进程已退出")
}
health, err := r.api.Health(ctx, &pluginv1.HealthRequest{})
if err != nil {
return fmt.Errorf("插件健康检查失败: %w", err)
}
if health == nil || !health.Healthy {
message := "插件报告不健康"
if health != nil && strings.TrimSpace(health.Message) != "" {
message = "插件不健康: " + health.Message
}
return errors.New(message)
}
return 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,
+54
View File
@@ -0,0 +1,54 @@
-- 管理员手动上传的本地进程插件。
-- 插件默认停用;账号表不保存任何插件字段。
-- 表名使用 sub2api 前缀,避免与部署数据库中已有的通用插件表冲突。
CREATE TABLE IF NOT EXISTS sub2api_plugin_installations (
id BIGSERIAL PRIMARY KEY,
plugin_key VARCHAR(160) NOT NULL UNIQUE,
name VARCHAR(160) NOT NULL,
version VARCHAR(64) NOT NULL,
description TEXT NOT NULL DEFAULT '',
author VARCHAR(160) NOT NULL DEFAULT '',
manifest JSONB NOT NULL DEFAULT '{}'::jsonb,
artifact_path TEXT NOT NULL,
install_path TEXT NOT NULL,
binary_path TEXT NOT NULL,
binary_sha256 VARCHAR(64) NOT NULL,
signature_status VARCHAR(32) NOT NULL DEFAULT 'unsigned',
state VARCHAR(32) NOT NULL DEFAULT 'disabled',
config_encrypted TEXT NOT NULL DEFAULT '',
last_error TEXT NOT NULL DEFAULT '',
installed_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
enabled_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT sub2api_plugin_installations_state_check
CHECK (state IN ('disabled', 'starting', 'enabled', 'error', 'incompatible')),
CONSTRAINT sub2api_plugin_installations_signature_status_check
CHECK (signature_status IN ('trusted', 'unsigned'))
);
CREATE TABLE IF NOT EXISTS sub2api_plugin_bindings (
id BIGSERIAL PRIMARY KEY,
plugin_id BIGINT NOT NULL REFERENCES sub2api_plugin_installations(id) ON DELETE CASCADE,
capability VARCHAR(160) NOT NULL,
platform VARCHAR(32) NOT NULL,
account_type VARCHAR(32) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
rollout_percent INTEGER NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT sub2api_plugin_bindings_rollout_check CHECK (rollout_percent BETWEEN 0 AND 100),
CONSTRAINT sub2api_plugin_bindings_scope_unique UNIQUE (plugin_id, capability, platform, account_type)
);
CREATE INDEX IF NOT EXISTS idx_sub2api_plugin_bindings_plugin_id
ON sub2api_plugin_bindings(plugin_id);
CREATE INDEX IF NOT EXISTS idx_sub2api_plugin_bindings_enabled_scope
ON sub2api_plugin_bindings(platform, account_type, capability)
WHERE enabled = TRUE;
CREATE UNIQUE INDEX IF NOT EXISTS idx_sub2api_plugin_bindings_one_enabled_scope
ON sub2api_plugin_bindings(capability, platform, account_type)
WHERE enabled = TRUE;
@@ -0,0 +1,4 @@
-- 保存经过签名校验的原始插件包,供多实例和无状态节点重新复验、解包。
-- 旧安装记录允许暂时为空;管理员重新上传该插件后会自动补齐。
ALTER TABLE sub2api_plugin_installations
ADD COLUMN IF NOT EXISTS artifact_data BYTEA;
@@ -0,0 +1,35 @@
package migrations
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestPluginsMigrationKeepsAccountSchemaUnchanged(t *testing.T) {
content, err := FS.ReadFile("229_plugins.sql")
require.NoError(t, err)
sql := strings.Join(strings.Fields(string(content)), " ")
require.Contains(t, sql, "CREATE TABLE IF NOT EXISTS sub2api_plugin_installations")
require.Contains(t, sql, "CREATE TABLE IF NOT EXISTS sub2api_plugin_bindings")
require.Contains(t, sql, "config_encrypted TEXT NOT NULL DEFAULT ''")
require.Contains(t, sql, "REFERENCES sub2api_plugin_installations(id)")
require.Contains(t, sql, "CREATE UNIQUE INDEX IF NOT EXISTS idx_sub2api_plugin_bindings_one_enabled_scope")
require.Contains(t, sql, "WHERE enabled = TRUE")
require.NotContains(t, sql, "CREATE TABLE IF NOT EXISTS plugin_installations")
require.NotContains(t, sql, "CREATE TABLE IF NOT EXISTS plugin_bindings")
require.NotContains(t, strings.ToUpper(sql), "ALTER TABLE ACCOUNTS")
require.NotContains(t, sql, "account_id")
}
func TestPluginArtifactMigrationSupportsExistingInstallations(t *testing.T) {
content, err := FS.ReadFile("230_plugin_artifacts.sql")
require.NoError(t, err)
sql := strings.Join(strings.Fields(string(content)), " ")
require.Contains(t, sql, "ALTER TABLE sub2api_plugin_installations")
require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS artifact_data BYTEA")
require.NotContains(t, strings.ToUpper(sql), "ALTER TABLE ACCOUNTS")
}
+80
View File
@@ -0,0 +1,80 @@
# Sub2API 本地插件协议
本目录是插件开发者可以依赖的公开契约。`v1/plugin.proto` 和 `v1/runtime.go` 定义进程协议,`v1/manifest.schema.json` 定义包清单,`docs/` 记录开发和发布规范。Provider 私有实现不应放入本目录。
## 开发文档
- [开发指南](docs/development.md):从运行时、配置到集成测试的完整流程。
- [UI Bridge](docs/ui-bridge.md):沙箱配置 UI 的消息结构和安全要求。
- [包格式](docs/package-format.md):清单、文件哈希、签名和版本规则。
- [安全边界](docs/security.md):进程权限、敏感数据和故障策略。
## 实体与运行方式
插件的交付实体是一个 `.s2plugin` 文件,本质上是带清单、签名、独立可执行文件和静态 UI 的 ZIP 包。管理员在独立的插件管理页手动上传,Sub2API 不从网络自动下载插件,也不要求 Docker。
启用后,Sub2API 以子进程方式拉起当前操作系统和 CPU 架构对应的二进制,通过本机 gRPC 流传递请求与响应。插件进程退出时会随 Sub2API 清理;停用时先停止接收新请求,再等待正在处理的请求结束。
多实例部署不要求共享插件目录。宿主会在数据库保存已验签的原始插件包,各实例缺少本地文件时会重新验签和解包,并周期性对齐启用状态、灰度比例和加密配置。所有实例必须连接同一数据库并使用相同的加密密钥。
独立进程是代码和发布边界,不是操作系统安全沙箱。插件拥有 Sub2API 服务用户所拥有的文件和网络权限,因此只应安装可信发布者的签名包。闭源二进制可提高源码分发门槛,但不能承诺无法反编译。
## 初期能力边界
当前只接受 `openai.oauth.outbound_transport.v1`:
- 仅匹配 `platform=openai` 且 `account_type=oauth` 的上游 HTTP 请求。
- API Key 账号、其他 provider、OAuth 登录与 Token 刷新流程不进入插件。
- 插件建立真实的上游 HTTP/TLS 连接并返回原始 HTTP 响应。
- 命中插件的 OAuth WebSocket 账号会使用 Sub2API 现有 HTTP Bridge,不直接建立上游 WebSocket,避免绕过 v1 HTTP 插件协议。
- Sub2API 继续负责响应状态处理、SSE 解析、错误映射、用量统计、计费和下游输出。
- 灰度比例以账号 ID 稳定分桶,未命中的 OAuth 账号继续使用原有内置路径。
## 包结构
```text
manifest.json
signature.json # 生产包必需
runtimes/linux-amd64/plugin
runtimes/linux-arm64/plugin
runtimes/windows-amd64/plugin.exe
ui/index.html
ui/assets/...
```
`manifest.json` 必须声明所有运行时和 UI 文件的 SHA-256。`signature.json` 使用受信任发布者的 Ed25519 私钥对 `manifest.json` 原始字节签名。官方 OpenAI Transport 公钥由宿主内置,第三方发布者公钥由部署者追加到 `plugins.trusted_publishers`。文件哈希由已签名清单保护。
插件默认保持停用。未签名包默认拒绝安装;`plugins.allow_unsigned` 只应用于开发者自己构建的本地调试包。
## 兼容性
清单必须同时声明:
- `requires.sub2api`:允许的 Sub2API 语义化版本范围。
- `requires.recommended_sub2api_version`:建议使用的宿主版本。
- `requires.tested_sub2api_versions`:发布者实际验证过的宿主版本。
- `plugin_protocol`、`transport_api`、`ui_bridge`:三个独立协议版本。
宿主版本超出范围时,插件可以安装并查看,但保持“不兼容”状态且不能启用。版本在范围内但未列入已测试版本时,管理员必须再次确认才能启用。
## UI 隔离与 Bridge
插件 UI 由包内静态文件实现,宿主使用只有 `allow-scripts` 权限的 sandbox iframe 加载。iframe 没有管理员 Token,也不能直接访问管理 API。宿主为每次打开配置页生成短时资源 URL 和独立 Bridge Token,并且同时校验消息来源窗口与 Token。
UI 可以发送以下消息:
- `config.load`
- `config.save`
- `config.test`
- `ui.resize`
- `ui.notify`
每个请求消息带 `request_id`,宿主以 `<type>.result` 返回结果。配置整体使用 Sub2API 的密钥加密后存入数据库;运行中插件会先验证并应用新配置,数据库写入失败时恢复旧配置。
## 协议源码
- `v1/plugin.proto`:稳定的进程间消息定义。
- `v1/runtime.go`:Go 插件进程启动入口和宿主客户端声明。
- `v1/manifest.schema.json`:`manifest.json` 的 JSON Schema。
插件通过进程协议协作,不使用 Go 动态链接,也不要求插件与 Sub2API 使用相同编译器或共享内存 ABI。
+67
View File
@@ -0,0 +1,67 @@
# 插件开发指南
## 稳定边界
当前宿主只支持 `openai.oauth.outbound_transport.v1`。插件负责建立实际上游 HTTP/TLS 连接,Sub2API 负责账号选择、OAuth Token 生命周期、下游协议、响应解析、SSE、错误映射、用量统计和计费。
插件不应修改 API Key 路径,也不应自行刷新或持久化 OAuth Token。
## 推荐结构
```text
plugin/
├── cmd/<plugin>/main.go
├── internal/config/
├── internal/transport/
├── ui/index.html
├── ui/assets/
├── tools/packager/
├── manifest.source.json
└── README.md
```
入口只调用 `pluginv1.Serve`。配置解析和传输实现放入独立包,以便不启动子进程就能单元测试。
## 运行时方法
| 方法 | 要求 |
|---|---|
| `GetInfo` | ID、版本、协议和能力必须与清单一致 |
| `Health` | 返回进程是否可以接受新请求,不执行昂贵探测 |
| `ValidateConfig` | 严格解析并返回完整规范化 JSON |
| `ApplyConfig` | 原子应用配置;失败时保留旧配置 |
| `TestConfig` | 验证当前环境和已保存配置,返回简短诊断 |
| `Forward` | 双向流式传输请求与原始 HTTP 响应 |
请求帧顺序:`start`、零到多个 `body_chunk`、`body_end`。响应帧顺序:`start`、零到多个 `body_chunk`、`end`。不能继续处理的错误使用 `error` 帧。
`request_sent` 必须如实表示请求是否可能已经到达上游。值为 `true` 时宿主禁止自动切换账号重放;只有能确认尚未调用上游 Transport 时才能返回 `false`。
## 配置
- JSON 字段统一使用 `snake_case`。
- 拒绝未知字段、非法范围和受保护请求头。
- 默认配置必须完整,空对象应规范化为所有默认字段。
- 保存时由插件先验证和应用,再由宿主加密写入数据库。
- 数据库写入失败时宿主会尝试恢复旧配置,插件必须允许重复应用。
## 资源管理
- 复用 HTTP Transport 和连接池,不要为每个请求创建新连接池。
- 配置切换后关闭旧空闲连接。
- 使用 stream context 取消 DNS、连接、上传和响应读取。
- 始终关闭上游响应体。
- 不在插件内无限缓存按账号区分的客户端。
## 最低测试集
- 配置默认值、未知字段、边界值和深复制。
- 插件身份及协议版本。
- 请求体分块、无请求体、固定 Content-Length。
- 响应状态、重复请求头、流式响应和响应读取错误。
- 上下文取消、插件退出和超时。
- 代理开启与禁用。
- 包哈希、签名、路径穿越和目标平台运行时。
- UI Bridge 加载、保存、测试、错误和超时。
发布前还应使用真实构建包运行宿主的插件进程集成测试。
@@ -0,0 +1,45 @@
# `.s2plugin` 包格式
`.s2plugin` 是 ZIP 文件,根目录必须包含 `manifest.json`,生产包还必须包含 `signature.json`。
## 标准布局
```text
manifest.json
signature.json
runtimes/<goos>-<goarch>/<binary>
ui/index.html
ui/assets/...
```
所有运行时和 UI 文件必须出现在 `manifest.files`,值为小写十六进制 SHA-256。清单和签名文件自身不写入 `files`。
包不允许绝对路径、父目录跳转、重复路径、符号链接、未声明文件或缺失文件。宿主还限制上传大小、解压后大小和文件数量。
## 清单
字段规范见 [`v1/manifest.schema.json`](../v1/manifest.schema.json)。版本字段含义:
- `version`:插件自身语义化版本。
- `requires.sub2api`:宿主硬兼容范围。
- `recommended_sub2api_version`:建议宿主版本。
- `tested_sub2api_versions`:发布者真实验证过的版本。
- `plugin_protocol`:进程握手协议。
- `transport_api`:请求和响应帧协议。
- `ui_bridge`:配置 UI 消息协议。
## 签名
`signature.json`:
```json
{
"algorithm": "ed25519",
"key_id": "publisher-key-id",
"signature": "BASE64_SIGNATURE"
}
```
签名对象是 `manifest.json` 的精确原始字节。发布者私钥不得进入插件包、源码仓库或 Sub2API 运行环境。部署者只配置 Base64 Ed25519 公钥。
默认生产配置拒绝未签名包。官方 OpenAI Transport 使用宿主内置公钥验签,不需要配置;其他发布者仍需配置 `trusted_publishers`。`allow_unsigned` 只用于开发者自己构建的本地包。
+29
View File
@@ -0,0 +1,29 @@
# 插件安全边界
## 能提供的隔离
- 私有实现以独立二进制交付,宿主公开源码不包含其业务逻辑。
- 进程协议避免 Go 动态链接和共享内存 ABI。
- 包签名和文件哈希防止未授权替换。
- UI 使用短时 URL、独立 Bridge Token 和 sandbox iframe。
- 插件故障时 OAuth 插件路径失败关闭,不静默切回另一种网络行为。
## 不能提供的保证
- 闭源二进制仍可能被逆向分析。
- 子进程不是操作系统沙箱。
- 插件拥有 Sub2API 服务用户可访问的文件、环境变量和网络权限。
- 包签名证明发布者身份,不证明实现无漏洞或符合 Provider 条款。
## 部署要求
- 官方 OpenAI Transport 使用宿主内置公钥;只向 `plugins.trusted_publishers` 添加经过审核的第三方公钥。
- 使用专用低权限系统用户运行 Sub2API。
- 限制该用户的文件权限、出站网络和环境变量。
- 不向插件环境注入无关密钥。
- 对插件升级保留旧包和回滚流程。
- 记录安装、启用、停用、配置和删除操作,但不记录配置明文。
## 敏感数据
插件处理真实 OAuth Authorization 请求头,必须避免将请求头、请求体、代理凭据和上游敏感响应写入日志或诊断消息。UI 配置中不应出现 OAuth Token。
+56
View File
@@ -0,0 +1,56 @@
# UI Bridge v1
## 加载方式
宿主为每次打开配置页创建短时 UI 会话:
```text
/api/v1/plugin-ui/<asset-token>/index.html#bridge_token=<bridge-token>
```
资源 Token 用于读取包内 `ui/` 文件,Bridge Token 只存在于 URL fragment,不会发送到服务器。iframe 使用 `sandbox="allow-scripts"`,不授予 `allow-same-origin`。
UI 只能加载包内、已在清单声明的资源。CSP 禁止外部网络连接、表单提交和外部 frame。
## 消息信封
UI 到宿主:
```json
{
"source": "sub2api-plugin-ui",
"bridge_token": "TOKEN",
"type": "config.load",
"request_id": "UNIQUE_ID"
}
```
宿主到 UI:
```json
{
"source": "sub2api-plugin-host",
"bridge_token": "TOKEN",
"request_id": "UNIQUE_ID",
"ok": true
}
```
## 方法
| `type` | UI 参数 | 成功响应 |
|---|---|---|
| `sub2api.plugin.ready` | 无 | 无响应 |
| `config.load` | 无 | `config` |
| `config.save` | `config` 对象 | 规范化后的 `config` |
| `config.test` | 无 | `result` |
| `ui.resize` | `height` | 无响应 |
| `ui.notify` | `level`、`message` | 无响应 |
`config.test` 在 v1 中测试已保存配置。UI 若要测试当前表单,应先调用 `config.save`。
## 必须执行的校验
UI 接收消息时必须验证 `event.source === parent`、消息来源标识、Bridge Token 和等待中的 `request_id`。每个请求必须有超时和卸载清理。
宿主不会向 iframe 提供管理员 Token。插件 UI 不得尝试访问管理 API、Cookie、父页面 DOM 或浏览器存储中的宿主数据。
@@ -0,0 +1,75 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sub2api.local/schemas/plugin-manifest-v1.json",
"title": "Sub2API Plugin Manifest v1",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "id", "name", "version", "requires", "capabilities", "runtimes", "ui", "files"],
"properties": {
"schema_version": { "const": 1 },
"id": { "type": "string", "maxLength": 160, "pattern": "^[a-z0-9]+([._-][a-z0-9]+)+$" },
"name": { "type": "string", "minLength": 1, "maxLength": 160 },
"version": { "type": "string", "pattern": "^v?(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$" },
"description": { "type": "string" },
"author": { "type": "string", "maxLength": 160 },
"requires": {
"type": "object",
"additionalProperties": false,
"required": ["sub2api", "plugin_protocol", "transport_api", "ui_bridge"],
"properties": {
"sub2api": { "type": "string", "minLength": 1 },
"recommended_sub2api_version": { "type": "string" },
"tested_sub2api_versions": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
"plugin_protocol": { "const": 1 },
"transport_api": { "const": 1 },
"ui_bridge": { "const": 1 }
}
},
"capabilities": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "platform", "account_type"],
"properties": {
"id": { "const": "openai.oauth.outbound_transport.v1" },
"platform": { "const": "openai" },
"account_type": { "const": "oauth" }
}
}
},
"runtimes": {
"type": "object",
"minProperties": 1,
"patternProperties": {
"^[a-z0-9]+-[a-z0-9]+$": {
"type": "object",
"additionalProperties": false,
"required": ["path"],
"properties": { "path": { "$ref": "#/$defs/safePath" } }
}
},
"additionalProperties": false
},
"ui": {
"type": "object",
"additionalProperties": false,
"required": ["entrypoint"],
"properties": { "entrypoint": { "type": "string", "pattern": "^ui/.+" } }
},
"files": {
"type": "object",
"minProperties": 2,
"propertyNames": { "$ref": "#/$defs/safePath" },
"additionalProperties": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
}
},
"$defs": {
"safePath": {
"type": "string",
"minLength": 1,
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$"
}
}
}
@@ -0,0 +1,21 @@
package pluginv1
import (
"encoding/json"
"os"
"testing"
)
func TestManifestSchemaIsValidJSON(t *testing.T) {
raw, err := os.ReadFile("manifest.schema.json")
if err != nil {
t.Fatalf("读取插件清单 Schema 失败: %v", err)
}
var schema map[string]any
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("插件清单 Schema 不是有效 JSON: %v", err)
}
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" {
t.Fatalf("插件清单 Schema 版本不符合预期: %v", schema["$schema"])
}
}
File diff suppressed because it is too large Load Diff
+117
View File
@@ -0,0 +1,117 @@
syntax = "proto3";
package sub2api.plugin.v1;
option go_package = "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1;pluginv1";
service TransportPlugin {
rpc GetInfo(GetInfoRequest) returns (GetInfoResponse);
rpc Health(HealthRequest) returns (HealthResponse);
rpc ValidateConfig(ValidateConfigRequest) returns (ValidateConfigResponse);
rpc ApplyConfig(ApplyConfigRequest) returns (ApplyConfigResponse);
rpc TestConfig(TestConfigRequest) returns (TestConfigResponse);
rpc Forward(stream ForwardRequest) returns (stream ForwardResponse);
}
message GetInfoRequest {}
message GetInfoResponse {
string plugin_id = 1;
string plugin_version = 2;
uint32 protocol_version = 3;
uint32 transport_api_version = 4;
repeated string capabilities = 5;
}
message HealthRequest {}
message HealthResponse {
bool healthy = 1;
string message = 2;
}
message ValidateConfigRequest {
bytes config_json = 1;
}
message ValidateConfigResponse {
bool valid = 1;
string message = 2;
bytes normalized_config_json = 3;
}
message ApplyConfigRequest {
bytes config_json = 1;
}
message ApplyConfigResponse {
bool applied = 1;
string message = 2;
}
message TestConfigRequest {
bytes config_json = 1;
}
message TestConfigResponse {
bool success = 1;
string message = 2;
int64 latency_ms = 3;
}
message HeaderValues {
repeated string values = 1;
}
message ForwardRequestStart {
string request_id = 1;
string method = 2;
string url = 3;
string host = 4;
map<string, HeaderValues> headers = 5;
string proxy_url = 6;
int64 account_id = 7;
int32 account_concurrency = 8;
string platform = 9;
string account_type = 10;
int64 content_length = 11;
bool has_body = 12;
}
message ForwardRequest {
oneof frame {
ForwardRequestStart start = 1;
bytes body_chunk = 2;
bool body_end = 3;
}
}
message ForwardResponseStart {
int32 status_code = 1;
string status = 2;
string protocol = 3;
int32 protocol_major = 4;
int32 protocol_minor = 5;
map<string, HeaderValues> headers = 6;
int64 content_length = 7;
}
message ForwardResponseEnd {
int64 bytes_received = 1;
int64 duration_ms = 2;
}
message ForwardResponseError {
string code = 1;
string message = 2;
bool request_sent = 3;
}
message ForwardResponse {
oneof frame {
ForwardResponseStart start = 1;
bytes body_chunk = 2;
ForwardResponseEnd end = 3;
ForwardResponseError error = 4;
}
}
+306
View File
@@ -0,0 +1,306 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v3.12.4
// source: plugin.proto
package pluginv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
TransportPlugin_GetInfo_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/GetInfo"
TransportPlugin_Health_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/Health"
TransportPlugin_ValidateConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/ValidateConfig"
TransportPlugin_ApplyConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/ApplyConfig"
TransportPlugin_TestConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/TestConfig"
TransportPlugin_Forward_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/Forward"
)
// TransportPluginClient is the client API for TransportPlugin service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type TransportPluginClient interface {
GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error)
Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error)
ValidateConfig(ctx context.Context, in *ValidateConfigRequest, opts ...grpc.CallOption) (*ValidateConfigResponse, error)
ApplyConfig(ctx context.Context, in *ApplyConfigRequest, opts ...grpc.CallOption) (*ApplyConfigResponse, error)
TestConfig(ctx context.Context, in *TestConfigRequest, opts ...grpc.CallOption) (*TestConfigResponse, error)
Forward(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ForwardRequest, ForwardResponse], error)
}
type transportPluginClient struct {
cc grpc.ClientConnInterface
}
func NewTransportPluginClient(cc grpc.ClientConnInterface) TransportPluginClient {
return &transportPluginClient{cc}
}
func (c *transportPluginClient) GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetInfoResponse)
err := c.cc.Invoke(ctx, TransportPlugin_GetInfo_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *transportPluginClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HealthResponse)
err := c.cc.Invoke(ctx, TransportPlugin_Health_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *transportPluginClient) ValidateConfig(ctx context.Context, in *ValidateConfigRequest, opts ...grpc.CallOption) (*ValidateConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ValidateConfigResponse)
err := c.cc.Invoke(ctx, TransportPlugin_ValidateConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *transportPluginClient) ApplyConfig(ctx context.Context, in *ApplyConfigRequest, opts ...grpc.CallOption) (*ApplyConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyConfigResponse)
err := c.cc.Invoke(ctx, TransportPlugin_ApplyConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *transportPluginClient) TestConfig(ctx context.Context, in *TestConfigRequest, opts ...grpc.CallOption) (*TestConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(TestConfigResponse)
err := c.cc.Invoke(ctx, TransportPlugin_TestConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *transportPluginClient) Forward(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ForwardRequest, ForwardResponse], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &TransportPlugin_ServiceDesc.Streams[0], TransportPlugin_Forward_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[ForwardRequest, ForwardResponse]{ClientStream: stream}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type TransportPlugin_ForwardClient = grpc.BidiStreamingClient[ForwardRequest, ForwardResponse]
// TransportPluginServer is the server API for TransportPlugin service.
// All implementations must embed UnimplementedTransportPluginServer
// for forward compatibility.
type TransportPluginServer interface {
GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error)
Health(context.Context, *HealthRequest) (*HealthResponse, error)
ValidateConfig(context.Context, *ValidateConfigRequest) (*ValidateConfigResponse, error)
ApplyConfig(context.Context, *ApplyConfigRequest) (*ApplyConfigResponse, error)
TestConfig(context.Context, *TestConfigRequest) (*TestConfigResponse, error)
Forward(grpc.BidiStreamingServer[ForwardRequest, ForwardResponse]) error
mustEmbedUnimplementedTransportPluginServer()
}
// UnimplementedTransportPluginServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedTransportPluginServer struct{}
func (UnimplementedTransportPluginServer) GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetInfo not implemented")
}
func (UnimplementedTransportPluginServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
}
func (UnimplementedTransportPluginServer) ValidateConfig(context.Context, *ValidateConfigRequest) (*ValidateConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ValidateConfig not implemented")
}
func (UnimplementedTransportPluginServer) ApplyConfig(context.Context, *ApplyConfigRequest) (*ApplyConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ApplyConfig not implemented")
}
func (UnimplementedTransportPluginServer) TestConfig(context.Context, *TestConfigRequest) (*TestConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method TestConfig not implemented")
}
func (UnimplementedTransportPluginServer) Forward(grpc.BidiStreamingServer[ForwardRequest, ForwardResponse]) error {
return status.Error(codes.Unimplemented, "method Forward not implemented")
}
func (UnimplementedTransportPluginServer) mustEmbedUnimplementedTransportPluginServer() {}
func (UnimplementedTransportPluginServer) testEmbeddedByValue() {}
// UnsafeTransportPluginServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to TransportPluginServer will
// result in compilation errors.
type UnsafeTransportPluginServer interface {
mustEmbedUnimplementedTransportPluginServer()
}
func RegisterTransportPluginServer(s grpc.ServiceRegistrar, srv TransportPluginServer) {
// If the following call panics, it indicates UnimplementedTransportPluginServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&TransportPlugin_ServiceDesc, srv)
}
func _TransportPlugin_GetInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetInfoRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TransportPluginServer).GetInfo(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: TransportPlugin_GetInfo_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TransportPluginServer).GetInfo(ctx, req.(*GetInfoRequest))
}
return interceptor(ctx, in, info, handler)
}
func _TransportPlugin_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HealthRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TransportPluginServer).Health(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: TransportPlugin_Health_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TransportPluginServer).Health(ctx, req.(*HealthRequest))
}
return interceptor(ctx, in, info, handler)
}
func _TransportPlugin_ValidateConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ValidateConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TransportPluginServer).ValidateConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: TransportPlugin_ValidateConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TransportPluginServer).ValidateConfig(ctx, req.(*ValidateConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _TransportPlugin_ApplyConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TransportPluginServer).ApplyConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: TransportPlugin_ApplyConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TransportPluginServer).ApplyConfig(ctx, req.(*ApplyConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _TransportPlugin_TestConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(TestConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TransportPluginServer).TestConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: TransportPlugin_TestConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TransportPluginServer).TestConfig(ctx, req.(*TestConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _TransportPlugin_Forward_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(TransportPluginServer).Forward(&grpc.GenericServerStream[ForwardRequest, ForwardResponse]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type TransportPlugin_ForwardServer = grpc.BidiStreamingServer[ForwardRequest, ForwardResponse]
// TransportPlugin_ServiceDesc is the grpc.ServiceDesc for TransportPlugin service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var TransportPlugin_ServiceDesc = grpc.ServiceDesc{
ServiceName: "sub2api.plugin.v1.TransportPlugin",
HandlerType: (*TransportPluginServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetInfo",
Handler: _TransportPlugin_GetInfo_Handler,
},
{
MethodName: "Health",
Handler: _TransportPlugin_Health_Handler,
},
{
MethodName: "ValidateConfig",
Handler: _TransportPlugin_ValidateConfig_Handler,
},
{
MethodName: "ApplyConfig",
Handler: _TransportPlugin_ApplyConfig_Handler,
},
{
MethodName: "TestConfig",
Handler: _TransportPlugin_TestConfig_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "Forward",
Handler: _TransportPlugin_Forward_Handler,
ServerStreams: true,
ClientStreams: true,
},
},
Metadata: "plugin.proto",
}
+59
View File
@@ -0,0 +1,59 @@
package pluginv1
import (
"context"
hcplugin "github.com/hashicorp/go-plugin"
"google.golang.org/grpc"
)
const (
// ProtocolVersion 是宿主与插件进程握手协议版本。
ProtocolVersion = 1
// TransportAPIVersion 是 OpenAI OAuth 出站传输契约版本。
TransportAPIVersion = 1
// UIBridgeVersion 是插件管理页与沙箱 UI 的消息协议版本。
UIBridgeVersion = 1
// TransportPluginName 是 go-plugin 中注册的唯一能力名称。
TransportPluginName = "oauth_transport"
)
// HandshakeConfig 防止普通可执行文件被误当成 Sub2API 插件启动。
var HandshakeConfig = hcplugin.HandshakeConfig{
ProtocolVersion: ProtocolVersion,
MagicCookieKey: "SUB2API_PLUGIN_MAGIC_COOKIE",
MagicCookieValue: "sub2api-plugin-v1",
}
// GRPCPlugin 把生成的 gRPC 服务注册到 go-plugin 子进程。
type GRPCPlugin struct {
hcplugin.NetRPCUnsupportedPlugin
Impl TransportPluginServer
}
func (p *GRPCPlugin) GRPCServer(_ *hcplugin.GRPCBroker, server *grpc.Server) error {
RegisterTransportPluginServer(server, p.Impl)
return nil
}
func (p *GRPCPlugin) GRPCClient(_ context.Context, _ *hcplugin.GRPCBroker, conn *grpc.ClientConn) (any, error) {
return NewTransportPluginClient(conn), nil
}
// ClientPluginMap 返回宿主侧使用的插件声明。
func ClientPluginMap() map[string]hcplugin.Plugin {
return map[string]hcplugin.Plugin{
TransportPluginName: &GRPCPlugin{},
}
}
// Serve 启动一个实现了传输协议的插件进程。
func Serve(impl TransportPluginServer) {
hcplugin.Serve(&hcplugin.ServeConfig{
HandshakeConfig: HandshakeConfig,
Plugins: map[string]hcplugin.Plugin{
TransportPluginName: &GRPCPlugin{Impl: impl},
},
GRPCServer: hcplugin.DefaultGRPCServer,
})
}
+21 -1
View File
@@ -181,7 +181,7 @@ security:
# 默认 CSP 策略(如果静态资源托管在其他域名,请自行覆盖)
# Note: __CSP_NONCE__ will be replaced with 'nonce-xxx' at request time for inline script security
# 注意:__CSP_NONCE__ 会在请求时被替换为 'nonce-xxx',用于内联脚本安全
policy: "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'"
policy: "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'"
proxy_probe:
# Allow skipping TLS verification for proxy probe (debug only)
# 允许代理探测时跳过 TLS 证书验证(仅用于调试)
@@ -1025,6 +1025,26 @@ rate_limit:
# 上游返回 529(过载)时的冷却时间(分钟)
overload_cooldown_minutes: 10
# =============================================================================
# Local OAuth Transport Plugins (Optional)
# 本地 OAuth 出站传输插件(可选)
# =============================================================================
plugins:
# Empty means {{DATA_DIR}}/plugins, or ./data/plugins when DATA_DIR is unset.
# 留空时使用 {{DATA_DIR}}/plugins;未设置 DATA_DIR 时使用 ./data/plugins。
data_dir: ""
# Keep false in production. Enable only for local development packages you built yourself.
# 生产环境应保持 false;仅调试自己构建的本地插件时临时开启。
allow_unsigned: false
# Additional Ed25519 public keys indexed by signature.json key_id.
# OpenAI Transport's public key is built in; this map only adds third-party publishers.
# 以 signature.json 中 key_id 为索引的额外 Ed25519 公钥。
# OpenAI Transport 公钥已内置,此处只需添加第三方发布者。
trusted_publishers: {}
max_upload_bytes: 134217728
max_uncompressed_bytes: 268435456
start_timeout_seconds: 15
# =============================================================================
# Pricing Data Source (Optional)
# 定价数据源(可选)
+233
View File
@@ -0,0 +1,233 @@
# Sub2API 插件开发教程
本文面向希望为 Sub2API 开发、打包和发布插件的团队。插件是独立进程和静态 UI 组成的 `.s2plugin` 包,宿主通过稳定的 gRPC 协议调用它。本文以当前宿主已经定义的 `openai.oauth.outbound_transport.v1` 能力作为协议示例,说明开发者需要准备什么、哪些职责属于插件、哪些职责仍由 Sub2API 负责。
本文不是一个可直接安装的完整插件,也不代表 Sub2API 已经发布对应的官方插件包。当前文档主要描述公开协议、宿主边界和开发流程。后续是否发布可安装包、支持哪些 Provider,以及如何提供示例仓库,都需要另行公告。
## 1. 准备开发环境
建议使用以下环境:
- Go 1.21 或更高版本;
- Node.js(仅在插件 UI 使用 JavaScript 时需要);
- Git;
- 与目标部署环境一致的构建工具链。
协议定义和通用说明位于:
- `backend/pkg/pluginapi/v1/plugin.proto`:进程间消息和流式请求定义;
- `backend/pkg/pluginapi/v1/runtime.go`:插件进程启动入口;
- `backend/pkg/pluginapi/v1/manifest.schema.json`:包清单 JSON Schema;
- `backend/pkg/pluginapi/docs/`:开发、UI Bridge、包格式和安全边界说明。
目前暂未提供可直接复制的官方示例源码。开发者可以按照本文的目录和协议说明创建自己的插件工程;示例仓库发布后,会在本文补充正式的获取地址、目录说明和版本要求。公开协议始终以 `backend/pkg/pluginapi/` 为准。
## 2. 创建插件工程
在示例仓库发布前,可以先创建一个独立的 Go 工程,目录建议如下:
```text
my-plugin/
├── cmd/<plugin>/main.go
├── internal/pluginconfig/
├── internal/transport/
├── ui/index.html
├── ui/assets/
├── tools/
├── manifest.source.json
└── build.sh
```
开发时至少准备以下部分:
1. `manifest.source.json`:插件 ID、名称、版本、作者、能力和兼容的 Sub2API 版本;
2. `cmd/<plugin>/main.go`:启动入口和运行时版本注入,并同步打包器中的构建目标和二进制名称;
3. `internal/pluginconfig/`:配置结构、默认值、严格校验和规范化;
4. `internal/transport/`:HTTP 客户端、代理、请求头、请求体、网络连接参数、响应流和资源回收;
5. `ui/index.html` 与 `ui/assets/`:插件自己的配置界面;
6. 单元测试、进程集成测试和目标平台构建配置。
入口文件应保持很小,只负责调用 `pluginv1.Serve`。实际逻辑放在可独立测试的包中,避免把配置解析、网络请求和协议组装全部写在 `main.go`。
## 3. 编写运行时
运行时实现 `TransportPlugin` 服务,必须满足以下约定:
| 方法 | 要求 |
| --- | --- |
| `GetInfo` | 返回的插件 ID、版本、协议版本、传输 API 版本和能力必须与清单一致。 |
| `Health` | 快速返回进程是否可以接收新请求,不执行长时间网络探测。 |
| `ValidateConfig` | 严格解析 JSON,拒绝未知字段和非法范围,并返回完整的规范化配置。 |
| `ApplyConfig` | 成功后原子切换配置;失败时保留旧配置和旧连接。 |
| `TestConfig` | 针对已保存配置进行快速诊断,返回简短、可展示的结果。 |
| `Forward` | 按协议接收请求流,发出上游请求,再按顺序返回响应流。 |
请求帧顺序为 `start`、零到多个 `body_chunk`、`body_end`;响应帧顺序为 `start`、零到多个 `body_chunk`、`end`。不能继续处理时发送 `error` 帧。
`ForwardResponseError.request_sent` 必须准确:只有在能够确认尚未调用上游 HTTP Transport 时才返回 `false`;一旦已经调用,或无法确认上游是否收到请求,就返回 `true`。宿主会据此决定是否允许切换账号重试,避免重复执行同一个请求。
资源管理也属于运行时契约:复用 HTTP Transport 和连接池,配置切换时关闭旧空闲连接,沿用 gRPC stream 的 context 取消 DNS、连接、上传和响应读取,并始终关闭上游响应体。日志和错误消息不能包含 Token、代理凭据、完整请求体或敏感响应头。
## 4. 设计插件配置
插件配置由插件定义,由 Sub2API 加密保存。推荐流程是:
1. 在 `internal/pluginconfig.Config` 中定义字段和默认值;
2. 使用 `json.Decoder.DisallowUnknownFields` 等严格方式解析;
3. 将空对象规范化为完整默认配置;
4. 在 `ValidateConfig` 和 `ApplyConfig` 中复用同一套校验;
5. 配置应用成功后再让宿主保存,保存失败时允许恢复旧配置。
JSON 字段统一使用 `snake_case`。敏感配置不要放入 URL、UI 通知、诊断结果或日志。插件不应从 UI 读取、刷新或持久化 OAuth Token;宿主只在运行时调用需要的网络转发接口。
## 5. 实现插件自己的配置 UI
UI 是插件包内的静态页面,不需要修改 Sub2API 前端源码。宿主会在受限 iframe 中加载 `ui/index.html`,并通过 UI Bridge 提供配置读写和测试能力。
页面初始化流程:
1. 加载包内 HTML、CSS 和 JavaScript;
2. 创建 Bridge 并注册 `message` 监听;
3. 发送 `sub2api.plugin.ready`;
4. 调用 `config.load` 渲染表单;
5. 编辑后调用 `config.save`;
6. 测试前先保存,再调用 `config.test`;
7. 页面卸载时调用 `dispose()`。
当前 Bridge 支持:
| 消息 | 用途 |
| --- | --- |
| `config.load` | 读取当前配置。 |
| `config.save` | 提交配置,由运行时校验、应用并加密保存。 |
| `config.test` | 运行已保存配置的诊断。 |
| `ui.resize` | 调整配置 iframe 高度。 |
| `ui.notify` | 显示成功、错误或提示消息。 |
每条消息都必须带 `request_id`,并校验 `event.source`、消息来源标识和 Bridge Token。不要依赖 CDN、远程脚本、Cookie 或本地存储。页面需要兼容窄屏和明暗主题,并正确处理加载、保存、测试、超时和未保存状态。
详细信封格式见 `backend/pkg/pluginapi/docs/ui-bridge.md`。如果后续示例仓库提供可复用的 Bridge SDK,本文会在示例仓库章节补充对应路径和使用方式。
## 6. 编写包清单
只维护 `manifest.source.json`,不要手工编辑构建目录中的 `manifest.json`。至少需要填写:
```json
{
"schema_version": 1,
"id": "example.openai.transport",
"name": "Example OpenAI Transport",
"version": "0.1.0",
"requires": {
"sub2api": ">=0.1.179 <0.2.0",
"recommended_sub2api_version": "0.1.179",
"tested_sub2api_versions": ["0.1.179"],
"plugin_protocol": 1,
"transport_api": 1,
"ui_bridge": 1
},
"capabilities": [
{
"id": "openai.oauth.outbound_transport.v1",
"platform": "openai",
"account_type": "oauth"
}
],
"runtimes": {},
"ui": { "entrypoint": "ui/index.html" },
"files": {}
}
```
打包器会自动填充目标平台运行时、UI 和运行时文件的 SHA-256。清单中的 `requires.sub2api` 是硬兼容范围;`tested_sub2api_versions` 应只填写真实验证过的版本;`recommended_sub2api_version` 用于管理页面展示。当前宿主仅处理 `openai.oauth.outbound_transport.v1`,声明其他能力不会自动产生新路由。后续增加 Provider 支持时,会在协议、能力清单和宿主路由完成适配后,再补充对应的清单示例。
## 7. 生成密钥并签名
生产包应始终签名,宿主默认拒绝未签名包。可以使用插件工程中的密钥生成工具生成一对 Ed25519 密钥;示例仓库发布后会提供标准工具和完整命令:
```bash
go run ./tools/keygen -out build/keys/my-publisher
```
生成的 `my-publisher.private` 只保存在受控的开发机或 CI Secret 中,不能提交到源码仓库、插件包或部署服务器。公钥是 Base64 文本,可以提供给部署者。
插件工程的 `build.sh` 应调用标准打包器。自定义发布者密钥时必须同时提供 `-signing-key` 和 `-key-id`:
```bash
./build.sh \
-signing-key /安全目录/my-publisher.private \
-key-id my-publisher-v1 \
-output dist/my-openai-plugin.s2plugin
```
签名覆盖最终 `manifest.json` 的精确字节;清单中的文件哈希再覆盖运行时和 UI 文件。签名完成后不要重新格式化 `manifest.json`。
部署者在 Sub2API 配置文件中追加公钥:
```yaml
plugins:
allow_unsigned: false
trusted_publishers:
my-publisher-v1: "BASE64_ED25519_PUBLIC_KEY"
```
`trusted_publishers` 是在宿主内置官方公钥之外追加的信任来源,不能覆盖内置公钥。`signature.json` 中的 `key_id` 必须与配置键完全一致。密钥轮换时先发布包含新公钥的宿主配置或版本,再发布新签名包,最后再停用旧密钥。
开发阶段如需使用未签名包,只应在隔离的本地环境临时设置 `plugins.allow_unsigned: true`,测试完成后立即恢复为 `false`。
## 8. 构建、测试和安装
在插件目录执行:
```bash
go test ./... -count=1
node --check ui/assets/bridge-v1.js
node --check ui/assets/app.js
./build.sh
unzip -t dist/*.s2plugin
```
回到 Sub2API 仓库根目录后,再使用真实构建包运行宿主集成测试:
```bash
cd ../..
SUB2API_TEST_PLUGIN_PACKAGE=plugins/my-openai-plugin/dist/my-openai-plugin.s2plugin \
go test ./backend/internal/service -run '^TestPluginRuntimeIntegration$' -count=1
```
最低测试集应覆盖配置默认值和边界值、插件身份、请求和响应分块、流式响应、上下文取消、插件退出、代理开关、包哈希、签名、路径安全、目标平台运行时以及 UI Bridge 的加载、保存、测试、错误和超时。
安装后先保持停用,确认清单兼容性、签名和诊断结果,再按账号灰度启用。API Key 账号和未命中灰度的 OAuth 账号继续走 Sub2API 原有路径。
## 9. 发布前检查清单
- 插件版本与 `GetInfo` 返回值一致;
- `requires.sub2api` 覆盖范围经过验证,没有未经测试的破坏性版本;
- `tested_sub2api_versions` 与实际测试记录一致;
- 每个支持的平台和架构都有运行时文件;
- 生产包存在有效 `signature.json`,公钥已交付部署者;
- 包中没有私钥、源映射、测试数据、日志和临时文件;
- UI 不依赖外部资源,也不保存宿主会话信息;
- 配置切换、请求取消、响应关闭和错误重试语义经过测试;
- 发布说明包含升级、停用、回滚和兼容版本信息。
## 10. 常见问题
| 现象 | 排查方向 |
| --- | --- |
| 安装提示签名不受信任 | 检查 `signature.json.key_id`、Base64 公钥和配置键是否完全一致。 |
| 插件显示不兼容 | 检查 `requires.sub2api`、`plugin_protocol`、`transport_api` 和 `ui_bridge`。 |
| 插件进程无法启动 | 检查目标系统和架构对应的运行时路径、可执行权限和运行用户权限。 |
| 配置页无法加载 | 检查 `ui.entrypoint`、UI 文件哈希、Bridge Token 校验和 iframe 消息来源。 |
| 保存后配置未生效 | 查看 `ValidateConfig`、`ApplyConfig` 返回的规范化配置和诊断信息。 |
| 请求失败后重复执行 | 检查 `ForwardResponseError.request_sent` 是否准确反映请求是否可能已发出。 |
## 11. 需要扩展能力时
如果新插件需要支持其他 Provider、其他账号类型或新的消息字段,应先扩展并版本化公开协议,再由宿主增加能力匹配和生命周期处理。不要仅通过清单声明一个宿主尚未实现的能力。这样可以让旧插件继续运行,也能让新宿主明确拒绝不兼容的插件。
Sub2API 后续会持续补充更多 Provider 的插件适配说明,包括能力标识、请求和响应契约、配置字段、UI Bridge 使用方式、版本兼容要求以及测试清单。本文会随着这些能力的落地继续更新,Provider 专属章节会放在本节之后。
## 12. 示例仓库预留
后续计划提供独立的插件示例仓库,用于存放可复用的运行时骨架、UI 组件、打包工具和各 Provider 的最小实现。目前示例仓库尚未准备完成,因此暂不提供地址;正式发布后会在这里补充仓库地址、适用的 Sub2API 版本、示例插件版本和构建说明。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

+11 -2
View File
@@ -35,6 +35,7 @@ import affiliatesAPI from './affiliates'
import riskControlAPI from './riskControl'
import adminComplianceAPI from './compliance'
import auditAPI from './audit'
import pluginsAPI from './plugins'
/**
* Unified admin API object for convenient access
@@ -71,7 +72,8 @@ export const adminAPI = {
affiliates: affiliatesAPI,
riskControl: riskControlAPI,
compliance: adminComplianceAPI,
audit: auditAPI
audit: auditAPI,
plugins: pluginsAPI
}
export {
@@ -106,7 +108,8 @@ export {
affiliatesAPI,
riskControlAPI,
adminComplianceAPI,
auditAPI
auditAPI,
pluginsAPI
}
export default adminAPI
@@ -118,3 +121,9 @@ export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from
export type { BackupAgentHealth, DataManagementConfig } from './dataManagement'
export type { TLSFingerprintProfile, CreateProfileRequest, UpdateProfileRequest } from './tlsFingerprintProfile'
export type { ContentModerationConfig, ContentModerationLog, ModerationMode } from './riskControl'
export type {
PluginInstallation,
PluginCompatibility,
PluginUISession,
PluginTestResult
} from './plugins'
+156
View File
@@ -0,0 +1,156 @@
import { apiClient } from '../client'
export interface PluginCapability {
id: string
platform: string
account_type: string
}
export interface PluginRequirements {
sub2api: string
recommended_sub2api_version?: string
tested_sub2api_versions?: string[]
plugin_protocol: number
transport_api: number
ui_bridge: number
}
export interface PluginManifest {
schema_version: number
id: string
name: string
version: string
description?: string
author?: string
requires: PluginRequirements
capabilities: PluginCapability[]
ui: { entrypoint: string }
}
export interface PluginCompatibility {
compatible: boolean
tested: boolean
status: 'compatible' | 'untested' | 'incompatible'
message: string
current_sub2api_version: string
required_sub2api_version: string
recommended_sub2api_version: string
plugin_protocol: number
transport_api: number
ui_bridge: number
}
export interface PluginBinding {
id: number
plugin_id: number
capability: string
platform: string
account_type: string
enabled: boolean
rollout_percent: number
}
export interface PluginInstallation {
id: number
plugin_key: string
name: string
version: string
description: string
author: string
manifest: PluginManifest
binary_sha256: string
signature_status: 'trusted' | 'unsigned'
state: 'disabled' | 'starting' | 'enabled' | 'error' | 'incompatible'
last_error: string
installed_at: string
enabled_at?: string
updated_at: string
bindings: PluginBinding[]
compatibility: PluginCompatibility
runtime_healthy: boolean
runtime_message: string
}
export interface PluginTestResult {
success: boolean
message: string
latency_ms: number
}
export interface PluginUISession {
url: string
bridge_token: string
ui_bridge_version: number
expires_at: string
}
export async function list(): Promise<PluginInstallation[]> {
const { data } = await apiClient.get<PluginInstallation[]>('/admin/plugins')
return data
}
export async function upload(file: File): Promise<PluginInstallation> {
const form = new FormData()
form.append('plugin', file)
const { data } = await apiClient.post<PluginInstallation>('/admin/plugins/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120000
})
return data
}
export async function enable(
id: number,
rolloutPercent: number,
acceptUntested: boolean
): Promise<PluginInstallation> {
const { data } = await apiClient.post<PluginInstallation>(`/admin/plugins/${id}/enable`, {
rollout_percent: rolloutPercent,
accept_untested: acceptUntested
})
return data
}
export async function disable(id: number): Promise<PluginInstallation> {
const { data } = await apiClient.post<PluginInstallation>(`/admin/plugins/${id}/disable`)
return data
}
export async function remove(id: number): Promise<void> {
await apiClient.delete(`/admin/plugins/${id}`)
}
export async function getConfig(id: number): Promise<Record<string, unknown>> {
const { data } = await apiClient.get<Record<string, unknown>>(`/admin/plugins/${id}/config`)
return data
}
export async function saveConfig(
id: number,
config: Record<string, unknown>
): Promise<Record<string, unknown>> {
const { data } = await apiClient.put<Record<string, unknown>>(`/admin/plugins/${id}/config`, config)
return data
}
export async function test(id: number): Promise<PluginTestResult> {
const { data } = await apiClient.post<PluginTestResult>(`/admin/plugins/${id}/test`)
return data
}
export async function createUISession(id: number): Promise<PluginUISession> {
const { data } = await apiClient.post<PluginUISession>(`/admin/plugins/${id}/ui-session`)
return data
}
export default {
list,
upload,
enable,
disable,
remove,
getConfig,
saveConfig,
test,
createUISession
}
+2
View File
@@ -727,6 +727,7 @@ export interface SystemSettings {
model_plaza_enabled: boolean;
model_plaza_require_auth: boolean;
model_plaza_description: string;
plugin_management_enabled: boolean;
// Affiliate (邀请返利) feature switch
affiliate_enabled: boolean;
@@ -1026,6 +1027,7 @@ export interface UpdateSettingsRequest {
model_plaza_enabled?: boolean;
model_plaza_require_auth?: boolean;
model_plaza_description?: string;
plugin_management_enabled?: boolean;
// Affiliate (邀请返利) feature switch
affiliate_enabled?: boolean;
@@ -193,6 +193,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
import VersionBadge from '@/components/common/VersionBadge.vue'
import Icon from '@/components/icons/Icon.vue'
import { sanitizeSvg } from '@/utils/sanitize'
import { sanitizeUrl } from '@/utils/url'
import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
@@ -472,6 +473,10 @@ const ServerIcon = {
)
}
const PluginIcon = {
render: () => h(Icon, { name: 'cube' })
}
const BellIcon = {
render: () =>
h(
@@ -685,6 +690,7 @@ const flagPayment = makeSidebarFlag(FeatureFlags.payment)
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
const flagRiskControl = makeSidebarFlag(FeatureFlags.riskControl)
const flagPluginManagement = makeSidebarFlag(FeatureFlags.pluginManagement)
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
const flagBatchImageAccess = () => canUseBatchImage.value
@@ -769,6 +775,7 @@ const adminNavItems = computed((): NavItem[] => {
},
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/plugins', label: t('nav.plugins'), icon: PluginIcon, featureFlag: flagPluginManagement },
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{
@@ -6,6 +6,7 @@ import ops from './ops'
import settings from './settings'
import audit from './audit'
import promptAudit from './promptAudit'
import plugins from './plugins'
export default {
...overview,
@@ -16,4 +17,5 @@ export default {
...settings,
...audit,
...promptAudit,
...plugins,
}
@@ -0,0 +1,50 @@
export default {
plugins: {
title: 'Plugin Management',
description: 'Install and manage isolated OAuth outbound transport plugins. API Key flows are unchanged.',
upload: 'Install plugin',
uploadHint: 'Only .s2plugin packages are accepted; trusted publisher signatures are required by default.',
runtimeNotice: 'Plugin installation, enable/disable, and configuration are handled dynamically by the Sub2API host and normally do not require a host restart. Restart only when the host version or host configuration changes according to your deployment process.',
menuNotice: 'The Plugin Management switch in System Settings controls only sidebar visibility; it does not stop loaded or running plugins.',
empty: 'No plugins installed',
emptyHint: 'Select a local .s2plugin package. Sub2API never downloads third-party plugins automatically.',
configure: 'Configure',
enable: 'Enable',
disable: 'Disable',
test: 'Test',
uninstall: 'Uninstall',
rollout: 'OAuth traffic percentage',
compatibility: 'Version compatibility',
currentVersion: 'Current Sub2API',
requiredVersion: 'Required range',
recommendedVersion: 'Recommended version',
signature: 'Package signature',
trusted: 'Verified',
unsigned: 'Unsigned',
runtime: 'Runtime',
healthy: 'Healthy',
unhealthy: 'Not running',
compatible: 'Compatible',
untested: 'Untested version',
incompatible: 'Incompatible',
enabled: 'Enabled',
disabled: 'Disabled',
error: 'Error',
starting: 'Starting',
configTitle: '{name} configuration',
loadingUI: 'Loading plugin configuration UI...',
uiUnavailable: 'Unable to load the plugin configuration UI',
uploadSuccess: 'Plugin installed and kept disabled',
enableSuccess: 'Plugin enabled',
disableSuccess: 'Plugin disabled',
uninstallSuccess: 'Plugin uninstalled',
testSuccess: 'Plugin test passed',
confirmDisable: 'Disable this plugin? New OAuth requests immediately return to the built-in Sub2API path.',
confirmUninstall: 'Uninstall this plugin? It must be disabled first. Installed files and configuration will be removed.',
confirmUntested: 'This plugin is compatible but has not declared the current Sub2API version as tested. Enable it anyway?',
fileRequired: 'Select a .s2plugin file',
bridgeRejected: 'Plugin UI message validation failed',
onlyOpenAI: 'Initial capability: OpenAI OAuth outbound transport only',
noAccountCoupling: 'The scope is platform and account type. Account records are not changed and no per-account toggle is required.'
}
}
@@ -52,6 +52,12 @@ export default {
priceDescription: 'Pricing notes (Markdown)',
priceDescriptionHint: 'Rendered at the top of the plaza page. Use it for billing rules, exchange rates, promotions, etc.',
},
pluginManagement: {
title: 'Plugin Management',
description: 'Controls whether the plugin management entry appears in the admin sidebar. This switch does not control plugin runtime state.',
enabled: 'Show Plugin Management',
enabledHint: 'Turning this off only hides the sidebar entry; loaded or running plugins are not stopped.',
},
riskControl: {
title: 'Risk Control',
description: 'Enable the content moderation menu and gateway audit entry point. Disabled by default.',
+1
View File
@@ -176,6 +176,7 @@ export default {
modelPlaza: 'Model Plaza',
subscriptions: 'Subscriptions',
accounts: 'Accounts',
plugins: 'Plugins',
proxies: 'Proxies',
redeemCodes: 'Redeem Codes',
ops: 'Ops',
@@ -6,6 +6,7 @@ import ops from './ops'
import settings from './settings'
import audit from './audit'
import promptAudit from './promptAudit'
import plugins from './plugins'
export default {
...overview,
@@ -16,4 +17,5 @@ export default {
...settings,
...audit,
...promptAudit,
...plugins,
}
@@ -0,0 +1,50 @@
export default {
plugins: {
title: '插件管理',
description: '安装和管理独立运行的 OAuth 出站传输插件。API Key 流程不受影响。',
upload: '安装插件',
uploadHint: '仅接受 .s2plugin 包;默认要求可信发布者签名。',
runtimeNotice: '插件安装、启用、停用和配置由 Sub2API 宿主动态处理,通常不需要重启宿主实例。只有宿主版本或宿主配置本身变化时,才按部署方式执行重启。',
menuNotice: '系统设置中的“插件管理”开关仅控制侧边栏菜单显示,不会停止已经加载或正在运行的插件。',
empty: '尚未安装插件',
emptyHint: '选择本机的 .s2plugin 文件进行安装。Sub2API 不会自动下载第三方插件。',
configure: '配置',
enable: '启用',
disable: '停用',
test: '测试',
uninstall: '卸载',
rollout: 'OAuth 流量比例',
compatibility: '版本兼容性',
currentVersion: '当前 Sub2API',
requiredVersion: '要求范围',
recommendedVersion: '建议版本',
signature: '包签名',
trusted: '已验证',
unsigned: '未签名',
runtime: '运行状态',
healthy: '运行正常',
unhealthy: '未运行',
compatible: '兼容',
untested: '未验证版本',
incompatible: '不兼容',
enabled: '已启用',
disabled: '已停用',
error: '异常',
starting: '启动中',
configTitle: '{name} 配置',
loadingUI: '正在加载插件配置界面...',
uiUnavailable: '无法加载插件配置界面',
uploadSuccess: '插件安装成功,当前保持停用',
enableSuccess: '插件已启用',
disableSuccess: '插件已停用',
uninstallSuccess: '插件已卸载',
testSuccess: '插件测试通过',
confirmDisable: '确定停用此插件吗?新的 OAuth 请求会立即恢复 Sub2API 原有路径。',
confirmUninstall: '确定卸载此插件吗?插件必须先停用。此操作会移除安装文件和配置。',
confirmUntested: '该插件兼容当前版本范围,但未声明已测试当前 Sub2API 版本。确定承担风险并启用吗?',
fileRequired: '请选择 .s2plugin 文件',
bridgeRejected: '插件 UI 消息校验失败',
onlyOpenAI: '初期能力:仅 OpenAI OAuth 出站传输',
noAccountCoupling: '作用域为平台与账号类型,不修改账号数据,也不需要在账号页逐个开启。'
}
}
@@ -52,6 +52,12 @@ export default {
priceDescription: '价格说明(Markdown)',
priceDescriptionHint: '展示在模型广场页面顶部,可用于说明计费规则、汇率、优惠活动等。',
},
pluginManagement: {
title: '插件管理',
description: '控制管理员侧边栏是否显示插件管理入口。此开关不控制插件运行状态。',
enabled: '显示插件管理菜单',
enabledHint: '关闭后仅隐藏侧边栏菜单;已加载或正在运行的插件不会因此停止。',
},
riskControl: {
title: '风控中心',
description: '启用内容审计菜单和全端点请求审核入口。默认关闭。',
+1
View File
@@ -176,6 +176,7 @@ export default {
modelPlaza: '模型广场',
subscriptions: '订阅管理',
accounts: '账号管理',
plugins: '插件管理',
proxies: 'IP管理',
redeemCodes: '兑换码',
ops: '运维监控',
+12
View File
@@ -524,6 +524,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.accounts.description'
}
},
{
path: '/admin/plugins',
name: 'AdminPlugins',
component: () => import('@/views/admin/PluginsView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Plugin Management',
titleKey: 'admin.plugins.title',
descriptionKey: 'admin.plugins.description'
}
},
{
path: '/admin/announcements',
name: 'AdminAnnouncements',
@@ -57,6 +57,7 @@ function createPublicSettings(overrides: Partial<PublicSettings> = {}): PublicSe
available_channels_enabled: false,
model_plaza_enabled: false,
model_plaza_require_auth: false,
plugin_management_enabled: false,
service_quota_enabled: false,
affiliate_enabled: false,
...overrides,
+1
View File
@@ -373,6 +373,7 @@ export const useAppStore = defineStore('app', () => {
available_channels_enabled: false,
model_plaza_enabled: false,
model_plaza_require_auth: false,
plugin_management_enabled: false,
risk_control_enabled: false,
service_quota_enabled: false,
affiliate_enabled: false,
+1
View File
@@ -273,6 +273,7 @@ export interface PublicSettings {
available_channels_enabled: boolean
model_plaza_enabled: boolean
model_plaza_require_auth: boolean
plugin_management_enabled: boolean
service_quota_enabled: boolean
affiliate_enabled: boolean
allow_user_view_error_requests?: boolean
+5
View File
@@ -109,6 +109,11 @@ export const FeatureFlags = {
mode: 'opt-in',
label: 'Model Plaza',
}),
pluginManagement: defineFlag({
key: 'plugin_management_enabled',
mode: 'opt-in',
label: 'Plugin Management',
}),
payment: defineFlag({
key: 'payment_enabled',
mode: 'opt-out',
+716
View File
@@ -0,0 +1,716 @@
<template>
<AppLayout>
<div class="space-y-6">
<section
class="flex flex-col gap-4 border-b border-gray-200 pb-5 dark:border-dark-700 sm:flex-row sm:items-end sm:justify-between"
>
<div class="min-w-0">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t("admin.plugins.title") }}
</h2>
<p class="mt-1 max-w-3xl text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.plugins.description") }}
</p>
<div
class="mt-3 flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300"
>
<span class="rounded bg-gray-100 px-2 py-1 dark:bg-dark-700">{{
t("admin.plugins.onlyOpenAI")
}}</span>
<span class="rounded bg-gray-100 px-2 py-1 dark:bg-dark-700">{{
t("admin.plugins.noAccountCoupling")
}}</span>
</div>
</div>
<div class="flex flex-shrink-0 items-center gap-2">
<input
ref="fileInput"
class="hidden"
type="file"
accept=".s2plugin,application/zip"
@change="handleFileSelected"
/>
<button
type="button"
class="btn btn-primary"
:disabled="uploading"
@click="fileInput?.click()"
>
<Icon name="upload" size="sm" />
{{ uploading ? t("common.processing") : t("admin.plugins.upload") }}
</button>
<button
type="button"
class="btn btn-secondary"
:disabled="loading"
:title="t('common.refresh')"
@click="loadPlugins"
>
<Icon name="refresh" size="sm" />
<span class="sr-only">{{ t("common.refresh") }}</span>
</button>
</div>
</section>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.plugins.uploadHint") }}
</p>
<div
class="border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200"
>
<p>{{ t("admin.plugins.runtimeNotice") }}</p>
<p class="mt-1">{{ t("admin.plugins.menuNotice") }}</p>
</div>
<div
v-if="loading"
class="flex min-h-48 items-center justify-center text-sm text-gray-500"
>
{{ t("common.loading") }}
</div>
<div
v-else-if="plugins.length === 0"
class="flex min-h-56 flex-col items-center justify-center border border-dashed border-gray-300 px-6 text-center dark:border-dark-600"
>
<Icon name="cube" size="xl" class="text-gray-400" />
<p class="mt-3 font-medium text-gray-800 dark:text-gray-200">
{{ t("admin.plugins.empty") }}
</p>
<p class="mt-1 max-w-lg text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.plugins.emptyHint") }}
</p>
</div>
<div v-else class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<article
v-for="plugin in plugins"
:key="plugin.id"
class="card overflow-hidden border border-gray-200 dark:border-dark-700"
>
<div
class="flex flex-wrap items-start justify-between gap-3 border-b border-gray-100 p-5 dark:border-dark-700"
>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h3
class="truncate text-base font-semibold text-gray-900 dark:text-white"
>
{{ plugin.name }}
</h3>
<span class="font-mono text-xs text-gray-500"
>v{{ plugin.version }}</span
>
<span
class="rounded px-2 py-0.5 text-xs font-medium"
:class="stateClass(plugin.state)"
>
{{ t(`admin.plugins.${plugin.state}`) }}
</span>
</div>
<p class="mt-1 text-xs text-gray-500">
{{ plugin.plugin_key
}}<span v-if="plugin.author"> · {{ plugin.author }}</span>
</p>
<p
v-if="plugin.description"
class="mt-2 text-sm text-gray-600 dark:text-gray-300"
>
{{ plugin.description }}
</p>
</div>
<button
type="button"
class="btn btn-secondary btn-sm"
@click="openConfiguration(plugin)"
>
<Icon name="cog" size="sm" />
{{ t("admin.plugins.configure") }}
</button>
</div>
<div class="grid grid-cols-1 gap-x-6 gap-y-4 p-5 md:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase text-gray-500">
{{ t("admin.plugins.compatibility") }}
</p>
<div class="mt-2 flex items-center gap-2">
<span
class="rounded px-2 py-0.5 text-xs font-medium"
:class="compatibilityClass(plugin.compatibility.status)"
>
{{ t(`admin.plugins.${plugin.compatibility.status}`) }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{
plugin.compatibility.message
}}</span>
</div>
<dl
class="mt-3 grid grid-cols-[auto,1fr] gap-x-3 gap-y-1 text-xs"
>
<dt class="text-gray-500">
{{ t("admin.plugins.currentVersion") }}
</dt>
<dd class="font-mono text-gray-800 dark:text-gray-200">
{{ plugin.compatibility.current_sub2api_version }}
</dd>
<dt class="text-gray-500">
{{ t("admin.plugins.requiredVersion") }}
</dt>
<dd class="font-mono text-gray-800 dark:text-gray-200">
{{ plugin.compatibility.required_sub2api_version }}
</dd>
<dt class="text-gray-500">
{{ t("admin.plugins.recommendedVersion") }}
</dt>
<dd class="font-mono text-gray-800 dark:text-gray-200">
{{ plugin.compatibility.recommended_sub2api_version || "-" }}
</dd>
</dl>
</div>
<div>
<p class="text-xs font-medium uppercase text-gray-500">
{{ t("admin.plugins.runtime") }}
</p>
<div class="mt-2 flex flex-wrap gap-2 text-xs">
<span
class="rounded px-2 py-0.5"
:class="
plugin.runtime_healthy
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'
"
>
{{
plugin.runtime_healthy
? t("admin.plugins.healthy")
: t("admin.plugins.unhealthy")
}}
</span>
<span
class="rounded bg-gray-100 px-2 py-0.5 text-gray-600 dark:bg-dark-700 dark:text-gray-300"
>
{{ t("admin.plugins.signature") }}:
{{ t(`admin.plugins.${plugin.signature_status}`) }}
</span>
</div>
<p
v-if="plugin.last_error"
class="mt-3 break-words text-xs text-red-600 dark:text-red-400"
>
{{ plugin.last_error }}
</p>
<p
v-else-if="plugin.runtime_message"
class="mt-3 break-words text-xs text-gray-500"
>
{{ plugin.runtime_message }}
</p>
</div>
<div class="md:col-span-2">
<label
class="flex items-center justify-between gap-4 text-xs font-medium text-gray-600 dark:text-gray-300"
>
<span>{{ t("admin.plugins.rollout") }}</span>
<span class="w-11 text-right font-mono"
>{{
rolloutValues[plugin.id] ?? currentRollout(plugin)
}}%</span
>
</label>
<input
:value="rolloutValues[plugin.id] ?? currentRollout(plugin)"
type="range"
min="1"
max="100"
step="1"
class="mt-2 w-full accent-primary-600"
:disabled="hasEnabledBinding(plugin)"
@input="setRollout(plugin.id, $event)"
/>
</div>
</div>
<div
class="flex flex-wrap justify-end gap-2 border-t border-gray-100 px-5 py-4 dark:border-dark-700"
>
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="busyID === plugin.id"
@click="testPlugin(plugin)"
>
<Icon name="beaker" size="sm" />
{{ t("admin.plugins.test") }}
</button>
<button
v-if="hasEnabledBinding(plugin)"
type="button"
class="btn btn-secondary btn-sm"
:disabled="busyID === plugin.id"
@click="disablePlugin(plugin)"
>
<Icon name="ban" size="sm" />
{{ t("admin.plugins.disable") }}
</button>
<button
v-else
type="button"
class="btn btn-primary btn-sm"
:disabled="
busyID === plugin.id ||
plugin.state === 'starting' ||
!plugin.compatibility.compatible
"
@click="enablePlugin(plugin)"
>
<Icon name="play" size="sm" />
{{ t("admin.plugins.enable") }}
</button>
<button
type="button"
class="btn btn-danger btn-sm"
:disabled="busyID === plugin.id || hasEnabledBinding(plugin)"
@click="uninstallPlugin(plugin)"
>
<Icon name="trash" size="sm" />
{{ t("admin.plugins.uninstall") }}
</button>
</div>
</article>
</div>
<BaseDialog
:show="configPlugin !== null"
:title="
t('admin.plugins.configTitle', { name: configPlugin?.name || '' })
"
width="full"
@close="closeConfiguration"
>
<div
class="relative min-h-[520px] overflow-hidden bg-gray-50 dark:bg-dark-900"
:style="{ height: `${iframeHeight}px` }"
>
<div
v-if="uiLoading"
class="absolute inset-0 z-10 flex items-center justify-center text-sm text-gray-500"
>
{{ t("admin.plugins.loadingUI") }}
</div>
<div
v-if="uiError"
class="absolute inset-0 z-20 flex flex-col items-center justify-center p-8 text-center"
>
<Icon name="exclamationTriangle" size="xl" class="text-amber-500" />
<p class="mt-3 font-medium text-gray-800 dark:text-gray-200">
{{ t("admin.plugins.uiUnavailable") }}
</p>
<p class="mt-1 max-w-xl text-sm text-gray-500">{{ uiError }}</p>
</div>
<iframe
v-if="uiSession"
ref="pluginFrame"
:src="uiSession.url"
sandbox="allow-scripts"
referrerpolicy="no-referrer"
class="h-full w-full border-0 bg-white dark:bg-dark-900"
:title="
t('admin.plugins.configTitle', { name: configPlugin?.name || '' })
"
@load="handlePluginFrameLoad"
/>
</div>
</BaseDialog>
<TotpStepUpDialog :controller="pluginStepUp" />
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import {
adminAPI,
type PluginInstallation,
type PluginUISession,
} from "@/api/admin";
import { useAppStore } from "@/stores";
import AppLayout from "@/components/layout/AppLayout.vue";
import BaseDialog from "@/components/common/BaseDialog.vue";
import Icon from "@/components/icons/Icon.vue";
import TotpStepUpDialog from "@/components/auth/TotpStepUpDialog.vue";
import {
isStepUpBlocked,
isStepUpCancelled,
stepUpBlockReason,
useStepUp,
} from "@/composables/useStepUp";
interface PluginBridgeMessage {
source?: string;
bridge_token?: string;
type?: string;
request_id?: string;
config?: unknown;
height?: unknown;
level?: unknown;
message?: unknown;
}
const { t } = useI18n();
const appStore = useAppStore();
const pluginStepUp = useStepUp();
const plugins = ref<PluginInstallation[]>([]);
const loading = ref(false);
const uploading = ref(false);
const busyID = ref<number | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const rolloutValues = ref<Record<number, number>>({});
const configPlugin = ref<PluginInstallation | null>(null);
const uiSession = ref<PluginUISession | null>(null);
const pluginFrame = ref<HTMLIFrameElement | null>(null);
const uiLoading = ref(false);
const uiError = ref("");
const iframeHeight = ref(640);
const pluginFrameLoaded = ref(false);
const pendingBridgeRequests = new Map<string, number>();
function errorMessage(error: unknown): string {
if (typeof error === "object" && error !== null && "message" in error) {
return String(
(error as { message?: unknown }).message || t("common.unknownError"),
);
}
return t("common.unknownError");
}
function reportSensitiveActionError(error: unknown): void {
if (isStepUpCancelled(error)) return;
if (isStepUpBlocked(error)) {
appStore.showError(
stepUpBlockReason(error) === "STEP_UP_ADMIN_API_KEY_FORBIDDEN"
? t("stepUp.adminApiKeyForbidden")
: t("stepUp.notEnabled"),
);
return;
}
appStore.showError(errorMessage(error));
}
async function loadPlugins(): Promise<void> {
loading.value = true;
try {
plugins.value = await adminAPI.plugins.list();
for (const plugin of plugins.value) {
rolloutValues.value[plugin.id] = currentRollout(plugin);
}
} catch (error: unknown) {
appStore.showError(errorMessage(error));
} finally {
loading.value = false;
}
}
async function handleFileSelected(event: Event): Promise<void> {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
target.value = "";
if (!file || !file.name.toLowerCase().endsWith(".s2plugin")) {
appStore.showError(t("admin.plugins.fileRequired"));
return;
}
uploading.value = true;
try {
await pluginStepUp.run(() => adminAPI.plugins.upload(file));
appStore.showSuccess(t("admin.plugins.uploadSuccess"));
await loadPlugins();
} catch (error: unknown) {
reportSensitiveActionError(error);
} finally {
uploading.value = false;
}
}
function currentRollout(plugin: PluginInstallation): number {
return (
plugin.bindings.find(
(binding) => binding.capability === "openai.oauth.outbound_transport.v1",
)?.rollout_percent || 100
);
}
function hasEnabledBinding(plugin: PluginInstallation): boolean {
return plugin.bindings.some((binding) => binding.enabled);
}
function setRollout(id: number, event: Event): void {
const value = Number((event.target as HTMLInputElement).value);
rolloutValues.value[id] = Math.min(100, Math.max(1, value));
}
async function enablePlugin(plugin: PluginInstallation): Promise<void> {
let acceptUntested = false;
if (!plugin.compatibility.tested) {
acceptUntested = window.confirm(t("admin.plugins.confirmUntested"));
if (!acceptUntested) return;
}
busyID.value = plugin.id;
try {
await pluginStepUp.run(() =>
adminAPI.plugins.enable(
plugin.id,
rolloutValues.value[plugin.id] || 100,
acceptUntested,
),
);
appStore.showSuccess(t("admin.plugins.enableSuccess"));
await loadPlugins();
} catch (error: unknown) {
reportSensitiveActionError(error);
} finally {
busyID.value = null;
}
}
async function disablePlugin(plugin: PluginInstallation): Promise<void> {
if (!window.confirm(t("admin.plugins.confirmDisable"))) return;
busyID.value = plugin.id;
try {
await pluginStepUp.run(() => adminAPI.plugins.disable(plugin.id));
appStore.showSuccess(t("admin.plugins.disableSuccess"));
await loadPlugins();
} catch (error: unknown) {
reportSensitiveActionError(error);
} finally {
busyID.value = null;
}
}
async function uninstallPlugin(plugin: PluginInstallation): Promise<void> {
if (!window.confirm(t("admin.plugins.confirmUninstall"))) return;
busyID.value = plugin.id;
try {
await pluginStepUp.run(() => adminAPI.plugins.remove(plugin.id));
appStore.showSuccess(t("admin.plugins.uninstallSuccess"));
await loadPlugins();
} catch (error: unknown) {
reportSensitiveActionError(error);
} finally {
busyID.value = null;
}
}
async function testPlugin(plugin: PluginInstallation): Promise<void> {
busyID.value = plugin.id;
try {
const result = await pluginStepUp.run(() =>
adminAPI.plugins.test(plugin.id),
);
if (result.success)
appStore.showSuccess(result.message || t("admin.plugins.testSuccess"));
else appStore.showError(result.message || t("common.error"));
} catch (error: unknown) {
reportSensitiveActionError(error);
} finally {
busyID.value = null;
}
}
async function openConfiguration(plugin: PluginInstallation): Promise<void> {
configPlugin.value = plugin;
uiSession.value = null;
pluginFrameLoaded.value = false;
clearPendingBridgeRequests();
uiLoading.value = true;
uiError.value = "";
iframeHeight.value = 640;
try {
uiSession.value = await adminAPI.plugins.createUISession(plugin.id);
} catch (error: unknown) {
uiLoading.value = false;
uiError.value = errorMessage(error);
}
}
function closeConfiguration(): void {
clearPendingBridgeRequests();
pluginFrameLoaded.value = false;
configPlugin.value = null;
uiSession.value = null;
uiLoading.value = false;
uiError.value = "";
}
function clearPendingBridgeRequests(): void {
for (const timeout of pendingBridgeRequests.values()) window.clearTimeout(timeout);
pendingBridgeRequests.clear();
}
function handlePluginFrameLoad(): void {
// A load can also be caused by a plugin navigating its iframe. Drop all
// outstanding responses so a late config response is never sent to the new document.
if (pluginFrameLoaded.value) clearPendingBridgeRequests();
pluginFrameLoaded.value = true;
uiLoading.value = false;
}
function registerBridgeRequest(requestID: string): void {
const timeout = window.setTimeout(() => {
pendingBridgeRequests.delete(requestID);
}, 30_000);
pendingBridgeRequests.set(requestID, timeout);
}
function postBridgeResult(
request: PluginBridgeMessage,
payload: Record<string, unknown>,
): void {
if (!pluginFrame.value?.contentWindow || !uiSession.value) return;
const requestID = typeof request.request_id === "string" ? request.request_id.trim() : "";
const timeout = pendingBridgeRequests.get(requestID);
if (!requestID || timeout === undefined) return;
window.clearTimeout(timeout);
pendingBridgeRequests.delete(requestID);
pluginFrame.value.contentWindow.postMessage(
{
source: "sub2api-plugin-host",
bridge_token: uiSession.value.bridge_token,
type: `${request.type}.result`,
request_id: requestID,
...payload,
},
// The sandboxed iframe has an opaque origin, so no fixed target origin exists.
// Pending request tracking plus load invalidation prevents cross-navigation leaks.
"*",
);
}
async function handleBridgeMessage(event: MessageEvent): Promise<void> {
if (
!uiSession.value ||
!configPlugin.value ||
event.source !== pluginFrame.value?.contentWindow ||
event.origin !== "null"
)
return;
const message = event.data as PluginBridgeMessage;
if (
!message ||
message.source !== "sub2api-plugin-ui" ||
message.bridge_token !== uiSession.value.bridge_token
)
return;
const requestID = typeof message.request_id === "string" ? message.request_id.trim() : "";
const expectsResponse =
message.type === "config.load" ||
message.type === "config.save" ||
message.type === "config.test";
if (expectsResponse) {
if (!requestID || pendingBridgeRequests.has(requestID)) return;
registerBridgeRequest(requestID);
}
try {
switch (message.type) {
case "sub2api.plugin.ready":
uiLoading.value = false;
break;
case "config.load": {
const config = await adminAPI.plugins.getConfig(configPlugin.value.id);
postBridgeResult(message, { ok: true, config });
break;
}
case "config.save": {
if (
!message.config ||
typeof message.config !== "object" ||
Array.isArray(message.config)
) {
throw new Error(t("admin.plugins.bridgeRejected"));
}
const config = await pluginStepUp.run(() =>
adminAPI.plugins.saveConfig(
configPlugin.value!.id,
message.config as Record<string, unknown>,
),
);
postBridgeResult(message, { ok: true, config });
appStore.showSuccess(t("common.saved"));
break;
}
case "config.test": {
const result = await pluginStepUp.run(() =>
adminAPI.plugins.test(configPlugin.value!.id),
);
postBridgeResult(message, { ok: result.success, result });
if (result.success)
appStore.showSuccess(
result.message || t("admin.plugins.testSuccess"),
);
else appStore.showError(result.message || t("common.error"));
break;
}
case "ui.resize": {
const height = Number(message.height);
if (Number.isFinite(height))
iframeHeight.value = Math.min(960, Math.max(520, Math.round(height)));
break;
}
case "ui.notify": {
const text =
typeof message.message === "string"
? message.message.slice(0, 500)
: "";
if (!text) break;
if (message.level === "error") appStore.showError(text);
else if (message.level === "success") appStore.showSuccess(text);
else appStore.showInfo(text);
break;
}
}
} catch (error: unknown) {
if (isStepUpBlocked(error)) reportSensitiveActionError(error);
postBridgeResult(message, {
ok: false,
error: isStepUpCancelled(error) ? t("common.cancel") : errorMessage(error),
});
}
}
function stateClass(state: PluginInstallation["state"]): string {
if (state === "enabled")
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
if (state === "error" || state === "incompatible")
return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300";
if (state === "starting")
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
return "bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300";
}
function compatibilityClass(
status: PluginInstallation["compatibility"]["status"],
): string {
if (status === "compatible")
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
if (status === "untested")
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300";
}
onMounted(() => {
window.addEventListener("message", handleBridgeMessage);
void loadPlugins();
});
onBeforeUnmount(() => {
window.removeEventListener("message", handleBridgeMessage);
clearPendingBridgeRequests();
});
</script>
+27
View File
@@ -7188,6 +7188,30 @@
</div>
</div>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.pluginManagement.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.pluginManagement.description') }}
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between gap-4">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.pluginManagement.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.pluginManagement.enabledHint') }}
</p>
</div>
<Toggle v-model="form.plugin_management_enabled" />
</div>
</div>
</div>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
@@ -9752,6 +9776,8 @@ const form = reactive<SettingsForm>({
model_plaza_enabled: false,
model_plaza_require_auth: false,
model_plaza_description: '',
// Plugin management menu visibility; plugin runtime is unaffected.
plugin_management_enabled: false,
// Affiliate (邀请返利) feature switch
affiliate_enabled: false,
// Allow user view error requests
@@ -11408,6 +11434,7 @@ async function saveSettings() {
model_plaza_enabled: form.model_plaza_enabled,
model_plaza_require_auth: form.model_plaza_require_auth,
model_plaza_description: form.model_plaza_description,
plugin_management_enabled: form.plugin_management_enabled,
// Affiliate (邀请返利) feature switch
affiliate_enabled: form.affiliate_enabled,
allow_user_view_error_requests: form.allow_user_view_error_requests,
@@ -0,0 +1,169 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PluginsView from '../PluginsView.vue'
const {
listPlugins,
uploadPlugin,
enablePlugin,
savePluginConfig,
createUISession,
stepUpRun,
} = vi.hoisted(() => ({
listPlugins: vi.fn(),
uploadPlugin: vi.fn(),
enablePlugin: vi.fn(),
savePluginConfig: vi.fn(),
createUISession: vi.fn(),
stepUpRun: vi.fn((action: () => Promise<unknown>) => action()),
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
plugins: {
list: listPlugins,
upload: uploadPlugin,
enable: enablePlugin,
disable: vi.fn(),
remove: vi.fn(),
getConfig: vi.fn().mockResolvedValue({}),
saveConfig: savePluginConfig,
test: vi.fn().mockResolvedValue({ success: true, message: 'ok', latency_ms: 1 }),
createUISession,
},
},
}))
vi.mock('@/stores', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showInfo: vi.fn(),
}),
}))
vi.mock('@/composables/useStepUp', () => ({
useStepUp: () => ({ run: stepUpRun }),
isStepUpBlocked: () => false,
isStepUpCancelled: () => false,
stepUpBlockReason: () => '',
}))
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('vue-i18n')>()),
useI18n: () => ({ t: (key: string) => key }),
}))
const plugin = {
id: 7,
plugin_key: 'local.test.transport',
name: 'Test Transport',
version: '1.0.0',
description: '',
author: 'test',
manifest: {
schema_version: 1,
id: 'local.test.transport',
name: 'Test Transport',
version: '1.0.0',
requires: {
sub2api: '>=0.1.0',
plugin_protocol: 1,
transport_api: 1,
ui_bridge: 1,
},
capabilities: [],
ui: { entrypoint: 'ui/index.html' },
},
binary_sha256: 'a'.repeat(64),
signature_status: 'trusted' as const,
state: 'disabled' as const,
last_error: '',
installed_at: '2026-08-22T00:00:00Z',
updated_at: '2026-08-22T00:00:00Z',
bindings: [
{
id: 1,
plugin_id: 7,
capability: 'openai.oauth.outbound_transport.v1',
platform: 'openai',
account_type: 'oauth',
enabled: false,
rollout_percent: 100,
},
],
compatibility: {
compatible: true,
tested: true,
status: 'compatible' as const,
message: '',
current_sub2api_version: '0.1.0',
required_sub2api_version: '>=0.1.0',
recommended_sub2api_version: '0.1.0',
plugin_protocol: 1,
transport_api: 1,
ui_bridge: 1,
},
runtime_healthy: false,
runtime_message: '',
}
function mountView() {
return mount(PluginsView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
BaseDialog: { template: '<div><slot /></div>' },
Icon: true,
TotpStepUpDialog: true,
},
},
})
}
describe('管理员插件页二次验证', () => {
beforeEach(() => {
vi.clearAllMocks()
stepUpRun.mockImplementation((action: () => Promise<unknown>) => action())
listPlugins.mockResolvedValue([plugin])
uploadPlugin.mockResolvedValue(plugin)
enablePlugin.mockResolvedValue(plugin)
savePluginConfig.mockResolvedValue({ enabled: true })
createUISession.mockResolvedValue({
url: '/api/v1/plugin-ui/token/index.html#bridge_token=bridge',
bridge_token: 'bridge',
ui_bridge_version: 1,
expires_at: '2026-08-22T01:00:00Z',
})
})
it('启用插件通过 step-up 控制器执行', async () => {
const wrapper = mountView()
await flushPromises()
const button = wrapper.findAll('button').find((item) => item.text().includes('admin.plugins.enable'))
expect(button).toBeDefined()
await button!.trigger('click')
await flushPromises()
expect(stepUpRun).toHaveBeenCalledTimes(1)
expect(enablePlugin).toHaveBeenCalledWith(7, 100, false)
})
it('上传插件通过 step-up 控制器执行', async () => {
const wrapper = mountView()
await flushPromises()
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', {
configurable: true,
value: [new File(['plugin'], 'transport.s2plugin', { type: 'application/zip' })],
})
await input.trigger('change')
await flushPromises()
expect(stepUpRun).toHaveBeenCalledTimes(1)
expect(uploadPlugin).toHaveBeenCalledTimes(1)
})
})