diff --git a/.dockerignore b/.dockerignore index d5369692c..c6167a25b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -49,6 +49,9 @@ coverage/ .env.* !.env.example +# 本地闭源插件目录可能包含发布签名私钥,绝不能进入 Docker 构建上下文。 +/plugins/ + # Local config config.yaml config.local.yaml diff --git a/.gitignore b/.gitignore index d778c275e..20ed12275 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a6fe5d42c..8494713ba 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index cf5984372..a38962ffd 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -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() } diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 6432afcfb..1300989dc 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -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() diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index 41e41bce1..a4858cc36 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -95,6 +95,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { nil, // ollamaCloudUsage nil, // auditLog nil, // promptAudit + nil, // pluginManager ) require.NotPanics(t, func() { diff --git a/backend/go.mod b/backend/go.mod index d5188b3ca..2f07042ce 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index cdea1a5b9..cbb257f12 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 048f2d76e..b8218d182 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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") } diff --git a/backend/internal/handler/admin/plugin_handler.go b/backend/internal/handler/admin/plugin_handler.go new file mode 100644 index 000000000..cdbbec60a --- /dev/null +++ b/backend/internal/handler/admin/plugin_handler.go @@ -0,0 +1,257 @@ +package admin + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +const pluginUISessionTTL = 30 * time.Minute + +// PluginHandler 提供插件安装、生命周期、配置和隔离 UI 资源接口。 +type PluginHandler struct { + manager *service.PluginManager +} + +func NewPluginHandler(manager *service.PluginManager) *PluginHandler { + return &PluginHandler{manager: manager} +} + +func (h *PluginHandler) List(c *gin.Context) { + plugins, err := h.manager.List(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, plugins) +} + +func (h *PluginHandler) Get(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + plugin, err := h.manager.Get(c.Request.Context(), id) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, plugin) +} + +func (h *PluginHandler) Upload(c *gin.Context) { + maxBytes := h.manager.MaxUploadBytes() + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+(1<<20)) + file, header, err := c.Request.FormFile("plugin") + if err != nil { + response.BadRequest(c, "请选择有效的 .s2plugin 文件") + return + } + defer func() { _ = file.Close() }() + if !strings.HasSuffix(strings.ToLower(header.Filename), ".s2plugin") { + response.BadRequest(c, "插件包扩展名必须是 .s2plugin") + return + } + var installedBy *int64 + if subject, ok := middleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 { + userID := subject.UserID + installedBy = &userID + } + plugin, err := h.manager.Install(c.Request.Context(), file, installedBy) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + c.JSON(http.StatusCreated, plugin) +} + +type pluginEnableRequest struct { + AcceptUntested bool `json:"accept_untested"` + RolloutPercent int `json:"rollout_percent"` +} + +func (h *PluginHandler) Enable(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + request := pluginEnableRequest{RolloutPercent: 100} + if err := c.ShouldBindJSON(&request); err != nil { + response.BadRequest(c, "启用参数无效") + return + } + plugin, err := h.manager.Enable(c.Request.Context(), id, request.AcceptUntested, request.RolloutPercent) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, plugin) +} + +func (h *PluginHandler) Disable(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + plugin, err := h.manager.Disable(c.Request.Context(), id) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, plugin) +} + +func (h *PluginHandler) Delete(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + if err := h.manager.Delete(c.Request.Context(), id); err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, gin.H{"message": "插件已卸载"}) +} + +func (h *PluginHandler) GetConfig(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + configJSON, err := h.manager.GetConfig(c.Request.Context(), id) + if err != nil { + response.ErrorFrom(c, err) + return + } + c.Data(http.StatusOK, "application/json; charset=utf-8", configJSON) +} + +func (h *PluginHandler) SaveConfig(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 4*1024*1024)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + response.BadRequest(c, "插件配置必须是有效 JSON") + return + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + response.BadRequest(c, "插件配置只能包含一个 JSON 值") + return + } + raw, err := json.Marshal(value) + if err != nil { + response.BadRequest(c, "插件配置无法序列化") + return + } + saved, err := h.manager.SaveConfig(c.Request.Context(), id, raw) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + c.Data(http.StatusOK, "application/json; charset=utf-8", saved) +} + +func (h *PluginHandler) Test(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + result, err := h.manager.Test(c.Request.Context(), id) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, result) +} + +func (h *PluginHandler) CreateUISession(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + assetToken, expires, err := h.manager.CreateUIAssetToken(c.Request.Context(), id, pluginUISessionTTL) + if err != nil { + response.ErrorFrom(c, err) + return + } + bridgeToken, err := randomPluginToken() + if err != nil { + response.InternalError(c, "创建插件 UI Bridge 失败") + return + } + response.Success(c, gin.H{ + "url": fmt.Sprintf("/api/v1/plugin-ui/%s/index.html#bridge_token=%s", assetToken, bridgeToken), + "bridge_token": bridgeToken, + "ui_bridge_version": 1, + "expires_at": expires, + }) +} + +// ServeUIAsset 使用短时随机能力 URL 提供插件静态资源,不向 iframe 暴露管理员凭据。 +func (h *PluginHandler) ServeUIAsset(c *gin.Context) { + token := strings.TrimSpace(c.Param("token")) + pluginID, err := h.manager.ResolveUIAssetToken(token) + if err != nil { + c.Status(http.StatusGone) + return + } + relative := strings.TrimPrefix(c.Param("path"), "/") + data, logicalPath, err := h.manager.ReadUIAsset(c.Request.Context(), pluginID, relative) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + c.Status(http.StatusNotFound) + return + } + c.Status(http.StatusNotFound) + return + } + contentType := mime.TypeByExtension(filepath.Ext(logicalPath)) + if contentType == "" { + contentType = "application/octet-stream" + } + c.Header("Cache-Control", "private, no-store") + c.Header("Referrer-Policy", "no-referrer") + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Frame-Options", "SAMEORIGIN") + // sandbox iframe 没有 allow-same-origin,会以不透明来源加载自己的 CSS/JS。 + // 资源 URL 由短时随机能力 Token 保护,Bridge Token 只存在于 fragment 中。 + c.Header("Cross-Origin-Resource-Policy", "cross-origin") + c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'") + c.Data(http.StatusOK, contentType, data) +} + +func pluginIDParam(c *gin.Context) (int64, bool) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + response.BadRequest(c, "插件 ID 无效") + return 0, false + } + return id, true +} + +func randomPluginToken() (string, error) { + buffer := make([]byte, 32) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + return hex.EncodeToString(buffer), nil +} diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 01a066165..4c8cc145d 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -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, diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index e2399d089..3775c01e1 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -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, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 798de2fed..0d0c1ed40 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -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"` diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 7c0d3921e..f7126904d 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -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 diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index f0785de99..d314a4f94 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -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, diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 1eef8c001..bd11f72f9 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -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, diff --git a/backend/internal/repository/plugin_repo.go b/backend/internal/repository/plugin_repo.go new file mode 100644 index 000000000..4c942a10d --- /dev/null +++ b/backend/internal/repository/plugin_repo.go @@ -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) diff --git a/backend/internal/repository/plugin_repo_integration_test.go b/backend/internal/repository/plugin_repo_integration_test.go new file mode 100644 index 000000000..c81f5d325 --- /dev/null +++ b/backend/internal/repository/plugin_repo_integration_test.go @@ -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)) +} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 34c1a1b9d..e6904f833 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -96,6 +96,7 @@ var ProviderSet = wire.NewSet( NewUserGroupRateRepository, NewErrorPassthroughRepository, NewTLSFingerprintProfileRepository, + NewPluginRepository, NewChannelRepository, NewChannelMonitorRepository, NewChannelMonitorV2Repository, diff --git a/backend/internal/server/middleware/security_headers.go b/backend/internal/server/middleware/security_headers.go index bb5d99abe..df2a3940c 100644 --- a/backend/internal/server/middleware/security_headers.go +++ b/backend/internal/server/middleware/security_headers.go @@ -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 + ";" } diff --git a/backend/internal/server/middleware/security_headers_test.go b/backend/internal/server/middleware/security_headers_test.go index cb355e0c4..cdd169c15 100644 --- a/backend/internal/server/middleware/security_headers_test.go +++ b/backend/internal/server/middleware/security_headers_test.go @@ -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 diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 60d6516bd..d618fb45e 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -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") { diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index a6f592ee2..451f90db1 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -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())) } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 0587c6167..467982377 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -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" diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index c22545f5f..e4fa52fe5 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -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) diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index ee5200301..02279b5c0 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -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{ diff --git a/backend/internal/service/openai_embeddings.go b/backend/internal/service/openai_embeddings.go index 366e11a22..87b190492 100644 --- a/backend/internal/service/openai_embeddings.go +++ b/backend/internal/service/openai_embeddings.go @@ -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, "") diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index bfd421bd5..5115dcfea 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -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) } diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 51ee5a252..aceb186fb 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -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) } diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index d4000f928..6e24fec39 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -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) } diff --git a/backend/internal/service/openai_gateway_count_tokens.go b/backend/internal/service/openai_gateway_count_tokens.go index 2cd4519d0..1480a2edd 100644 --- a/backend/internal/service/openai_gateway_count_tokens.go +++ b/backend/internal/service/openai_gateway_count_tokens.go @@ -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, "") diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 957d38a8c..3327a2f6a 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -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 { diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index b8ab02401..8a5aa809b 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -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) } diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go index f04ab5ae4..0050d10bf 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go @@ -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) } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index bee1eb1cd..5c799777c 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -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) } diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index 79ab1a685..8f107300b 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -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) } diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 23c283529..88f571709 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -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 diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index 88cf83310..423cba345 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -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) } diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 742cd732e..71e585f09 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -420,6 +420,7 @@ type OpenAIGatewayService struct { billingCacheService *BillingCacheService userGroupRateResolver *userGroupRateResolver httpUpstream HTTPUpstream + pluginManager *PluginManager deferredService *DeferredService openAITokenProvider *OpenAITokenProvider grokTokenProvider *GrokTokenProvider diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index 08f94fca6..bc6b6b807 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -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()) diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 7e32dde8d..9e72aaecc 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -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()) diff --git a/backend/internal/service/openai_live.go b/backend/internal/service/openai_live.go index 0d9f10554..68d3bb725 100644 --- a/backend/internal/service/openai_live.go +++ b/backend/internal/service/openai_live.go @@ -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 diff --git a/backend/internal/service/openai_plugin_transport.go b/backend/internal/service/openai_plugin_transport.go new file mode 100644 index 000000000..82b8d359e --- /dev/null +++ b/backend/internal/service/openai_plugin_transport.go @@ -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) +} diff --git a/backend/internal/service/openai_upstream_transport_error.go b/backend/internal/service/openai_upstream_transport_error.go index 4093ca0b6..b0f486aa9 100644 --- a/backend/internal/service/openai_upstream_transport_error.go +++ b/backend/internal/service/openai_upstream_transport_error.go @@ -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) } diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index b4a875234..2974964f9 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -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 { diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 3f5d1e57b..7a4300661 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -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) diff --git a/backend/internal/service/plugin_compatibility.go b/backend/internal/service/plugin_compatibility.go new file mode 100644 index 000000000..02c3ba344 --- /dev/null +++ b/backend/internal/service/plugin_compatibility.go @@ -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 +} diff --git a/backend/internal/service/plugin_compatibility_test.go b/backend/internal/service/plugin_compatibility_test.go new file mode 100644 index 000000000..f819c41d5 --- /dev/null +++ b/backend/internal/service/plugin_compatibility_test.go @@ -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")) +} diff --git a/backend/internal/service/plugin_manager.go b/backend/internal/service/plugin_manager.go new file mode 100644 index 000000000..a9d8b5a9e --- /dev/null +++ b/backend/internal/service/plugin_manager.go @@ -0,0 +1,1048 @@ +package service + +import ( + "bytes" + "context" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" +) + +const ( + pluginConfigMaxBytes = 4 * 1024 * 1024 + pluginUIAssetMaxBytes = 32 * 1024 * 1024 + pluginReconcilePeriod = time.Second + pluginUITokenPrefix = "sub2api:plugin-ui:v1:" +) + +type pluginRoute struct { + pluginID int64 + runtime *pluginRuntime + rolloutPercent int + unavailable string +} + +// PluginManager 管理插件安装、配置、进程生命周期和 OpenAI OAuth 能力绑定。 +type PluginManager struct { + repo PluginRepository + encryptor SecretEncryptor + cfg *config.Config + hostInfo PluginHostInfo + installer *PluginPackageInstaller + + operationMu sync.Mutex + mu sync.Mutex + runtimes map[int64]*pluginRuntime + localInstallations map[int64]*PluginInstallation + started bool + reconcileCancel context.CancelFunc + reconcileDone chan struct{} + route atomic.Pointer[pluginRoute] +} + +func NewPluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo) *PluginManager { + return &PluginManager{ + repo: repo, + encryptor: encryptor, + cfg: cfg, + hostInfo: hostInfo, + installer: NewPluginPackageInstaller(cfg, hostInfo), + runtimes: make(map[int64]*pluginRuntime), + localInstallations: make(map[int64]*PluginInstallation), + } +} + +func (m *PluginManager) MaxUploadBytes() int64 { + if m == nil || m.cfg == nil { + return 0 + } + return m.cfg.Plugins.MaxUploadBytes +} + +func (m *PluginManager) Start(ctx context.Context) error { + m.operationMu.Lock() + m.mu.Lock() + if m.started { + m.mu.Unlock() + m.operationMu.Unlock() + return nil + } + if err := os.MkdirAll(filepath.Join(m.installer.RootDir(), "runtime"), 0o700); err != nil { + m.mu.Unlock() + m.operationMu.Unlock() + return fmt.Errorf("创建插件运行目录: %w", err) + } + reconcileCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) + m.started = true + m.reconcileCancel = cancel + m.reconcileDone = make(chan struct{}) + done := m.reconcileDone + m.mu.Unlock() + m.operationMu.Unlock() + + go m.reconcileLoop(reconcileCtx, done) + if err := m.reconcileOnce(reconcileCtx); err != nil { + slog.Warn("plugin_initial_reconcile_failed", "error", err) + } + return nil +} + +func (m *PluginManager) Stop() { + m.mu.Lock() + cancel := m.reconcileCancel + done := m.reconcileDone + m.reconcileCancel = nil + m.reconcileDone = nil + m.mu.Unlock() + if cancel != nil { + cancel() + } + if done != nil { + <-done + } + m.operationMu.Lock() + m.mu.Lock() + runtimes := make([]*pluginRuntime, 0, len(m.runtimes)) + for _, runtime := range m.runtimes { + runtimes = append(runtimes, runtime) + } + m.runtimes = make(map[int64]*pluginRuntime) + m.route.Store(nil) + m.started = false + m.mu.Unlock() + m.operationMu.Unlock() + for _, runtime := range runtimes { + runtime.drain(10 * time.Second) + } +} + +func (m *PluginManager) List(ctx context.Context) ([]*PluginInstallation, error) { + plugins, err := m.repo.List(ctx) + if err != nil { + return nil, err + } + m.mu.Lock() + defer m.mu.Unlock() + route := m.route.Load() + for _, installation := range plugins { + installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo) + if runtime := m.runtimes[installation.ID]; runtime != nil && !runtime.client.Exited() { + installation.RuntimeHealthy = true + installation.RuntimeMessage = "插件进程运行中" + } else if installation.State == PluginStateEnabled { + installation.RuntimeMessage = installation.LastError + } + if route != nil && route.pluginID == installation.ID && route.runtime == nil { + installation.RuntimeMessage = route.unavailable + } + } + return plugins, nil +} + +func (m *PluginManager) Get(ctx context.Context, id int64) (*PluginInstallation, error) { + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo) + m.mu.Lock() + runtime := m.runtimes[id] + m.mu.Unlock() + installation.RuntimeHealthy = runtime != nil && !runtime.client.Exited() + if installation.RuntimeHealthy { + installation.RuntimeMessage = "插件进程运行中" + } else if route := m.route.Load(); route != nil && route.pluginID == id { + installation.RuntimeMessage = route.unavailable + } + return installation, nil +} + +func (m *PluginManager) Install(ctx context.Context, reader io.Reader, installedBy *int64) (*PluginInstallation, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + packageInfo, err := m.installer.Install(ctx, reader, installedBy) + if err != nil { + return nil, err + } + var previous *PluginInstallation + if existing, getErr := m.repo.GetByKey(ctx, packageInfo.PluginKey); getErr == nil { + if existing.State == PluginStateEnabled || hasEnabledOpenAIBinding(existing.Bindings) { + cleanupErr := m.cleanupInstallationFiles(packageInfo) + return nil, errors.Join(errors.New("请先停用当前插件,再上传同 ID 的新版本"), cleanupErr) + } + previous = existing + } else if !errors.Is(getErr, sql.ErrNoRows) { + cleanupErr := m.cleanupInstallationFiles(packageInfo) + return nil, errors.Join(getErr, cleanupErr) + } + bindings := make([]PluginBinding, 0, len(packageInfo.Manifest.Capabilities)) + for _, capability := range packageInfo.Manifest.SortedCapabilities() { + bindings = append(bindings, PluginBinding{ + Capability: capability.ID, + Platform: capability.Platform, + AccountType: capability.AccountType, + Enabled: false, + RolloutPercent: 100, + }) + } + installed, err := m.repo.Install(ctx, packageInfo, bindings) + if err != nil { + cleanupErr := m.cleanupInstallationFiles(packageInfo) + return nil, errors.Join(err, cleanupErr) + } + local := *packageInfo + local.ID = installed.ID + local.ConfigEncrypted = installed.ConfigEncrypted + local.Bindings = append([]PluginBinding(nil), installed.Bindings...) + m.mu.Lock() + localPrevious := m.localInstallations[installed.ID] + m.localInstallations[installed.ID] = &local + m.mu.Unlock() + if previous != nil { + if cleanupErr := m.cleanupInstallationFiles(previous); cleanupErr != nil { + slog.Warn("plugin_previous_install_cleanup_failed", "plugin_id", previous.ID, "error", cleanupErr) + } + if localPrevious != nil && (localPrevious.InstallPath != previous.InstallPath || localPrevious.ArtifactPath != previous.ArtifactPath) { + if cleanupErr := m.cleanupInstallationFiles(localPrevious); cleanupErr != nil { + slog.Warn("plugin_previous_local_install_cleanup_failed", "plugin_id", previous.ID, "error", cleanupErr) + } + } + } + return m.Get(ctx, installed.ID) +} + +func (m *PluginManager) cleanupInstallationFiles(installation *PluginInstallation) error { + if installation == nil { + return nil + } + var cleanupErr error + for _, path := range []string{installation.ArtifactPath, installation.InstallPath} { + if err := m.removeManagedPath(path); err != nil { + cleanupErr = errors.Join(cleanupErr, err) + } + } + return cleanupErr +} + +func (m *PluginManager) reconcileLoop(ctx context.Context, done chan struct{}) { + defer close(done) + ticker := time.NewTicker(pluginReconcilePeriod) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := m.reconcileOnce(ctx); err != nil && !errors.Is(err, context.Canceled) { + slog.Warn("plugin_reconcile_failed", "error", err) + } + } + } +} + +// reconcileOnce 以数据库中的绑定为权威状态,让每个实例独立恢复并启动同一插件。 +func (m *PluginManager) reconcileOnce(ctx context.Context) error { + m.operationMu.Lock() + defer m.operationMu.Unlock() + + installations, err := m.repo.List(ctx) + if err != nil { + // 无法读取权威绑定状态时不能假设插件未启用,否则会把 OAuth 请求静默回落到旧直连路径。 + m.publishUnavailableRoute(0, 100, "插件启用状态暂时无法读取") + return fmt.Errorf("读取插件启用状态: %w", err) + } + var enabled *PluginInstallation + for _, installation := range installations { + if !hasEnabledOpenAIBinding(installation.Bindings) { + continue + } + if enabled != nil { + err := errors.New("检测到多个 OpenAI OAuth 出站插件同时启用") + m.publishUnavailableRoute(enabled.ID, 100, err.Error()) + return err + } + enabled = installation + } + if enabled == nil { + for _, installation := range installations { + if installation.State != PluginStateStarting || !m.startingStateExpired(installation) { + continue + } + if err := m.repo.UpdateState( + ctx, installation.ID, PluginStateDisabled, "插件启动超时,已自动恢复为停用状态", nil, + installation.BinarySHA256, PluginStateStarting, + ); err != nil && !errors.Is(err, ErrPluginStateChanged) { + return fmt.Errorf("恢复超时插件状态: %w", err) + } + } + runtimes := m.detachAllRuntimes() + for _, runtime := range runtimes { + runtime.drain(10 * time.Second) + } + return nil + } + + rollout := bindingRollout(enabled.Bindings) + current := m.route.Load() + if current != nil && current.pluginID == enabled.ID && current.runtime != nil && + !current.runtime.client.Exited() && current.rolloutPercent == rollout && + current.runtime.installation.BinarySHA256 == enabled.BinarySHA256 && + current.runtime.installation.ConfigEncrypted == enabled.ConfigEncrypted { + if enabled.State == PluginStateError || (enabled.State == PluginStateStarting && m.startingStateExpired(enabled)) { + return m.repo.MarkRuntimeHealthy(ctx, enabled.ID, enabled.BinarySHA256, enabled.ConfigEncrypted) + } + return nil + } + if enabled.State == PluginStateStarting && !m.startingStateExpired(enabled) { + if current == nil { + m.route.Store(&pluginRoute{pluginID: enabled.ID, rolloutPercent: rollout, unavailable: "插件正在其他实例中启动"}) + } + return nil + } + + local, err := m.ensureLocalInstallation(ctx, enabled) + if err != nil { + m.publishUnavailableRoute(enabled.ID, rollout, err.Error()) + return err + } + runtime, err := m.prepareRuntime(ctx, local, true) + if err != nil { + m.publishUnavailableRoute(enabled.ID, rollout, err.Error()) + return err + } + + // 启动进程期间绑定可能已在其他实例上变化,发布前必须重新确认。 + latest, err := m.repo.GetByID(ctx, enabled.ID) + if err != nil { + runtime.kill() + return err + } + if !hasEnabledOpenAIBinding(latest.Bindings) || latest.BinarySHA256 != enabled.BinarySHA256 || + latest.ConfigEncrypted != enabled.ConfigEncrypted || bindingRollout(latest.Bindings) != rollout { + runtime.kill() + return nil + } + if latest.State == PluginStateStarting && !m.startingStateExpired(latest) { + runtime.kill() + return nil + } + if err := m.repo.MarkRuntimeHealthy(ctx, enabled.ID, enabled.BinarySHA256, enabled.ConfigEncrypted); err != nil { + runtime.kill() + if errors.Is(err, ErrPluginStateChanged) { + return nil + } + return err + } + + m.mu.Lock() + stale := make([]*pluginRuntime, 0, len(m.runtimes)) + for id, candidate := range m.runtimes { + if candidate != runtime { + candidate.draining.Store(true) + stale = append(stale, candidate) + } + if id != enabled.ID { + delete(m.runtimes, id) + } + } + m.runtimes[enabled.ID] = runtime + m.route.Store(&pluginRoute{pluginID: enabled.ID, runtime: runtime, rolloutPercent: rollout}) + m.mu.Unlock() + for _, candidate := range stale { + candidate.drain(10 * time.Second) + } + return nil +} + +func (m *PluginManager) startingStateExpired(installation *PluginInstallation) bool { + if installation == nil || installation.UpdatedAt.IsZero() { + return false + } + startTimeout := 15 * time.Second + if m.cfg != nil && m.cfg.Plugins.StartTimeoutSeconds > 0 { + startTimeout = time.Duration(m.cfg.Plugins.StartTimeoutSeconds) * time.Second + } + recoveryDelay := startTimeout + 45*time.Second + if recoveryDelay < time.Minute { + recoveryDelay = time.Minute + } + return time.Since(installation.UpdatedAt) > recoveryDelay +} + +func (m *PluginManager) detachAllRuntimes() []*pluginRuntime { + m.mu.Lock() + defer m.mu.Unlock() + runtimes := make([]*pluginRuntime, 0, len(m.runtimes)) + for id, runtime := range m.runtimes { + runtime.draining.Store(true) + runtimes = append(runtimes, runtime) + delete(m.runtimes, id) + } + m.route.Store(nil) + return runtimes +} + +func (m *PluginManager) publishUnavailableRoute(pluginID int64, rollout int, message string) { + m.mu.Lock() + stale := make([]*pluginRuntime, 0, len(m.runtimes)) + for id, runtime := range m.runtimes { + runtime.draining.Store(true) + stale = append(stale, runtime) + delete(m.runtimes, id) + } + m.route.Store(&pluginRoute{pluginID: pluginID, rolloutPercent: rollout, unavailable: message}) + m.mu.Unlock() + for _, runtime := range stale { + runtime.drain(10 * time.Second) + } +} + +func (m *PluginManager) ensureLocalInstallation(ctx context.Context, installation *PluginInstallation) (*PluginInstallation, error) { + if installation == nil { + return nil, errors.New("插件安装记录为空") + } + m.mu.Lock() + local := m.localInstallations[installation.ID] + m.mu.Unlock() + if local != nil && local.BinarySHA256 == installation.BinarySHA256 && local.Version == installation.Version { + if err := verifyLocalPluginBinary(local, m.installer.RootDir()); err == nil { + return mergeLocalInstallation(local, installation), nil + } + } + if err := verifyLocalPluginBinary(installation, m.installer.RootDir()); err == nil { + local = mergeLocalInstallation(installation, installation) + m.mu.Lock() + m.localInstallations[installation.ID] = local + m.mu.Unlock() + return local, nil + } + + artifact, err := m.repo.GetArtifact(ctx, installation.ID) + if err != nil { + return nil, fmt.Errorf("读取插件包原件: %w", err) + } + if len(artifact) == 0 { + return nil, errors.New("插件包原件缺失,请重新上传插件") + } + restored, err := m.installer.Install(ctx, bytes.NewReader(artifact), installation.InstalledBy) + if err != nil { + return nil, fmt.Errorf("恢复并复验插件包: %w", err) + } + if !samePluginPackage(restored, installation) { + cleanupErr := m.cleanupInstallationFiles(restored) + return nil, errors.Join(errors.New("数据库插件包与安装记录不一致"), cleanupErr) + } + local = mergeLocalInstallation(restored, installation) + m.mu.Lock() + m.localInstallations[installation.ID] = local + m.mu.Unlock() + return local, nil +} + +func mergeLocalInstallation(local, persisted *PluginInstallation) *PluginInstallation { + merged := *persisted + merged.ArtifactData = nil + merged.ArtifactPath = local.ArtifactPath + merged.InstallPath = local.InstallPath + merged.BinaryPath = local.BinaryPath + merged.Bindings = append([]PluginBinding(nil), persisted.Bindings...) + return &merged +} + +func samePluginPackage(local, persisted *PluginInstallation) bool { + if local == nil || persisted == nil || local.PluginKey != persisted.PluginKey || + local.Version != persisted.Version || local.BinarySHA256 != persisted.BinarySHA256 { + return false + } + localManifest, localErr := json.Marshal(local.Manifest) + persistedManifest, persistedErr := json.Marshal(persisted.Manifest) + return localErr == nil && persistedErr == nil && bytes.Equal(localManifest, persistedManifest) +} + +func verifyLocalPluginBinary(installation *PluginInstallation, root string) error { + if installation == nil { + return errors.New("插件安装记录为空") + } + rootPath, err := filepath.Abs(root) + if err != nil { + return err + } + installPath, err := filepath.Abs(installation.InstallPath) + if err != nil { + return err + } + relative, err := filepath.Rel(rootPath, installPath) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return errors.New("插件安装目录不在受管目录内") + } + runtimeEntry, ok := installation.Manifest.Runtimes[installation.Manifest.RuntimeKey()] + if !ok { + return errors.New("插件未声明当前平台运行时") + } + binaryPath, err := safePluginJoin(installPath, runtimeEntry.Path) + if err != nil { + return err + } + data, err := os.ReadFile(binaryPath) + if err != nil { + return err + } + digest := sha256.Sum256(data) + if hex.EncodeToString(digest[:]) != installation.BinarySHA256 { + return errors.New("本地插件二进制哈希不匹配") + } + installation.BinaryPath = binaryPath + return nil +} + +func (m *PluginManager) Enable(ctx context.Context, id int64, acceptUntested bool, rolloutPercent int) (*PluginInstallation, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + if rolloutPercent < 1 || rolloutPercent > 100 { + return nil, errors.New("灰度比例必须在 1 到 100 之间") + } + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if active := m.route.Load(); active != nil && active.pluginID != id { + return nil, errors.New("OpenAI OAuth 出站能力已有启用插件,请先停用当前插件") + } + if installation.State == PluginStateEnabled && hasEnabledOpenAIBinding(installation.Bindings) { + installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo) + m.mu.Lock() + runtime := m.runtimes[id] + m.mu.Unlock() + installation.RuntimeHealthy = runtime != nil && !runtime.client.Exited() + if installation.RuntimeHealthy { + return installation, nil + } + } + compatibility := EvaluatePluginCompatibility(installation.Manifest, m.hostInfo) + if !compatibility.Compatible { + stateErr := m.repo.UpdateState(ctx, id, PluginStateIncompatible, compatibility.Message, nil, installation.BinarySHA256, installation.State) + return nil, errors.Join(errors.New(compatibility.Message), stateErr) + } + if !compatibility.Tested && !acceptUntested { + return nil, errors.New("插件未声明已测试当前 Sub2API 版本,需要管理员确认后启用") + } + installation, err = m.ensureLocalInstallation(ctx, installation) + if err != nil { + return nil, err + } + originalBindings := append([]PluginBinding(nil), installation.Bindings...) + for index := range installation.Bindings { + installation.Bindings[index].Enabled = true + installation.Bindings[index].RolloutPercent = rolloutPercent + } + if err := m.repo.BeginEnable(ctx, id, installation.BinarySHA256, installation.State); err != nil { + return nil, err + } + runtime, err := m.prepareRuntime(ctx, installation, true) + if err != nil { + stateCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + stateErr := m.repo.UpdateState(stateCtx, id, PluginStateError, err.Error(), nil, installation.BinarySHA256, PluginStateStarting) + cancel() + if hasEnabledOpenAIBinding(originalBindings) { + m.route.Store(&pluginRoute{pluginID: id, rolloutPercent: bindingRollout(originalBindings), unavailable: err.Error()}) + } + return nil, errors.Join(err, stateErr) + } + now := time.Now() + if err := m.repo.UpdateBindingsAndState(ctx, id, installation.Bindings, PluginStateEnabled, "", &now, PluginStateStarting, installation.BinarySHA256); err != nil { + runtime.kill() + if errors.Is(err, ErrPluginStateChanged) { + return nil, err + } + stateCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + stateErr := m.repo.UpdateState(stateCtx, id, PluginStateError, err.Error(), nil, installation.BinarySHA256, PluginStateStarting) + cancel() + return nil, errors.Join(err, stateErr) + } + m.mu.Lock() + m.publishRuntimeLocked(installation, runtime) + m.mu.Unlock() + result, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + result.Compatibility = compatibility + result.RuntimeHealthy = true + result.RuntimeMessage = "插件进程运行中" + return result, nil +} + +func (m *PluginManager) Disable(ctx context.Context, id int64) (*PluginInstallation, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + m.mu.Lock() + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + m.mu.Unlock() + return nil, err + } + for index := range installation.Bindings { + installation.Bindings[index].Enabled = false + } + if err := m.repo.UpdateBindingsAndState(ctx, id, installation.Bindings, PluginStateDisabled, "", nil, "", installation.BinarySHA256); err != nil { + m.mu.Unlock() + return nil, err + } + runtime := m.removeRuntimeLocked(id) + m.mu.Unlock() + if runtime != nil { + runtime.drain(10 * time.Second) + } + return m.Get(ctx, id) +} + +func (m *PluginManager) Delete(ctx context.Context, id int64) error { + m.operationMu.Lock() + defer m.operationMu.Unlock() + m.mu.Lock() + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + m.mu.Unlock() + return err + } + if installation.State == PluginStateEnabled || hasEnabledOpenAIBinding(installation.Bindings) { + m.mu.Unlock() + return errors.New("请先停用插件,再执行卸载") + } + if err := m.repo.Delete(ctx, id, installation.BinarySHA256); err != nil { + m.mu.Unlock() + return err + } + runtime := m.removeRuntimeLocked(id) + local := m.localInstallations[id] + delete(m.localInstallations, id) + m.mu.Unlock() + if runtime != nil { + runtime.drain(10 * time.Second) + } + cleanupErr := m.cleanupInstallationFiles(installation) + if local != nil && (local.InstallPath != installation.InstallPath || local.ArtifactPath != installation.ArtifactPath) { + cleanupErr = errors.Join(cleanupErr, m.cleanupInstallationFiles(local)) + } + return cleanupErr +} + +func (m *PluginManager) GetConfig(ctx context.Context, id int64) (json.RawMessage, error) { + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + return m.decryptConfig(installation) +} + +func (m *PluginManager) SaveConfig(ctx context.Context, id int64, raw json.RawMessage) (json.RawMessage, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + if len(raw) == 0 || len(raw) > pluginConfigMaxBytes || !json.Valid(raw) { + return nil, errors.New("插件配置必须是有效且大小受限的 JSON") + } + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + previousConfig, err := m.decryptConfig(installation) + if err != nil { + return nil, err + } + var normalized any + if err := json.Unmarshal(raw, &normalized); err != nil { + return nil, err + } + canonical, err := json.Marshal(normalized) + if err != nil { + return nil, err + } + m.mu.Lock() + runtime := m.runtimes[id] + m.mu.Unlock() + temporary := false + if runtime == nil { + installation, err = m.ensureLocalInstallation(ctx, installation) + if err != nil { + return nil, err + } + runtime, err = m.newRuntime(ctx, installation) + if err != nil { + return nil, err + } + temporary = true + defer runtime.kill() + } + if runtime != nil { + applyCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + canonical, err = runtime.validateAndApplyNormalizedConfig(applyCtx, canonical) + cancel() + if err != nil { + return nil, err + } + } + encrypted, err := m.encryptor.Encrypt(string(canonical)) + if err != nil { + if !temporary { + err = errors.Join(err, m.restoreRuntimeConfig(id, runtime, previousConfig)) + } + return nil, fmt.Errorf("加密插件配置: %w", err) + } + if err := m.repo.UpdateConfig(ctx, id, encrypted, installation.BinarySHA256); err != nil { + if !temporary { + err = errors.Join(err, m.restoreRuntimeConfig(id, runtime, previousConfig)) + } + return nil, err + } + if !temporary { + runtime.installation.ConfigEncrypted = encrypted + } + return canonical, nil +} + +func (m *PluginManager) restoreRuntimeConfig(id int64, runtime *pluginRuntime, previous json.RawMessage) error { + if runtime == nil { + return nil + } + rollbackCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := runtime.validateAndApplyConfig(rollbackCtx, previous); err != nil { + route := m.route.Load() + if route != nil && route.pluginID == id && route.runtime == runtime { + stateErr := m.markRuntimeUnavailable(route, "插件配置回滚失败: "+err.Error()) + return errors.Join(err, stateErr) + } + return err + } + return nil +} + +func (m *PluginManager) Test(ctx context.Context, id int64) (*pluginv1.TestConfigResponse, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + configJSON, err := m.decryptConfig(installation) + if err != nil { + return nil, err + } + m.mu.Lock() + runtime := m.runtimes[id] + m.mu.Unlock() + temporary := false + if runtime == nil { + installation, err = m.ensureLocalInstallation(ctx, installation) + if err != nil { + return nil, err + } + runtime, err = m.newRuntime(ctx, installation) + if err != nil { + return nil, err + } + temporary = true + } + if temporary { + defer runtime.kill() + } + testCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if err := runtime.validateAndApplyConfig(testCtx, configJSON); err != nil { + return nil, err + } + return runtime.api.TestConfig(testCtx, &pluginv1.TestConfigRequest{ConfigJson: configJSON}) +} + +type pluginUIAssetClaims struct { + Version int `json:"version"` + PluginID int64 `json:"plugin_id"` + Expires int64 `json:"expires"` +} + +// CreateUIAssetToken 创建可跨实例校验的短时能力令牌,令牌不包含管理员凭据。 +func (m *PluginManager) CreateUIAssetToken(ctx context.Context, id int64, ttl time.Duration) (string, time.Time, error) { + if ttl <= 0 || ttl > time.Hour { + return "", time.Time{}, errors.New("插件 UI 会话有效期无效") + } + if _, err := m.repo.GetByID(ctx, id); err != nil { + return "", time.Time{}, err + } + expires := time.Now().Add(ttl) + raw, err := json.Marshal(pluginUIAssetClaims{Version: 1, PluginID: id, Expires: expires.Unix()}) + if err != nil { + return "", time.Time{}, err + } + // 加用途前缀,避免复用同一 AES-GCM 密钥的其他密文被当作 UI 能力令牌。 + encrypted, err := m.encryptor.Encrypt(pluginUITokenPrefix + string(raw)) + if err != nil { + return "", time.Time{}, fmt.Errorf("加密插件 UI 会话: %w", err) + } + return base64.RawURLEncoding.EncodeToString([]byte(encrypted)), expires, nil +} + +func (m *PluginManager) ResolveUIAssetToken(token string) (int64, error) { + if len(token) == 0 || len(token) > 4096 { + return 0, errors.New("插件 UI 会话无效") + } + encrypted, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return 0, errors.New("插件 UI 会话无效") + } + plaintext, err := m.encryptor.Decrypt(string(encrypted)) + if err != nil { + return 0, errors.New("插件 UI 会话无效") + } + plaintext, ok := strings.CutPrefix(plaintext, pluginUITokenPrefix) + if !ok { + return 0, errors.New("插件 UI 会话无效") + } + var claims pluginUIAssetClaims + decoder := json.NewDecoder(strings.NewReader(plaintext)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&claims); err != nil || claims.Version != 1 || claims.PluginID <= 0 { + return 0, errors.New("插件 UI 会话无效") + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return 0, errors.New("插件 UI 会话无效") + } + now := time.Now().Unix() + if now >= claims.Expires { + return 0, errors.New("插件 UI 会话已过期") + } + if claims.Expires > now+int64(time.Hour/time.Second) { + return 0, errors.New("插件 UI 会话无效") + } + return claims.PluginID, nil +} + +func (m *PluginManager) ReadUIAsset(ctx context.Context, id int64, relative string) ([]byte, string, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, "", err + } + installation, err = m.ensureLocalInstallation(ctx, installation) + if err != nil { + return nil, "", err + } + path := strings.TrimPrefix(strings.ReplaceAll(relative, "\\", "/"), "/") + if path == "" || path == "index.html" { + path = installation.Manifest.UI.Entrypoint + } else { + path = "ui/" + path + } + if _, declared := installation.Manifest.Files[path]; !declared || !strings.HasPrefix(path, "ui/") { + return nil, "", os.ErrNotExist + } + fullPath, err := safePluginJoin(installation.InstallPath, path) + if err != nil { + return nil, "", err + } + file, err := os.Open(fullPath) + if err != nil { + return nil, "", err + } + defer func() { _ = file.Close() }() + data, err := io.ReadAll(io.LimitReader(file, pluginUIAssetMaxBytes+1)) + if err != nil { + return nil, "", err + } + if len(data) > pluginUIAssetMaxBytes { + return nil, "", errors.New("插件 UI 资源超过大小限制") + } + digest := sha256.Sum256(data) + if hex.EncodeToString(digest[:]) != installation.Manifest.Files[path] { + return nil, "", errors.New("插件 UI 资源哈希不匹配") + } + return data, path, nil +} + +func (m *PluginManager) RoundTripOpenAIOAuth(ctx context.Context, request *http.Request, proxyURL string, account *Account) (*http.Response, bool, error) { + if !m.ShouldRouteOpenAIOAuth(account) { + return nil, false, nil + } + route := m.route.Load() + if route == nil { + return nil, false, nil + } + if route.runtime == nil { + return nil, true, fmt.Errorf("OpenAI OAuth 插件不可用: %s", route.unavailable) + } + if route.runtime.client.Exited() { + runtimeErr := errors.New("OpenAI OAuth 插件进程已退出") + if stateErr := m.markRuntimeUnavailable(route, runtimeErr.Error()); stateErr != nil { + return nil, true, errors.Join(runtimeErr, stateErr) + } + return nil, true, runtimeErr + } + if !route.runtime.beginRequest() { + return nil, true, errors.New("OpenAI OAuth 插件正在停止") + } + response, err := route.runtime.roundTrip(ctx, request, proxyURL, account) + if err != nil { + route.runtime.finishRequest() + if route.runtime.client.Exited() { + if stateErr := m.markRuntimeUnavailable(route, err.Error()); stateErr != nil { + err = errors.Join(err, stateErr) + } + } + return nil, true, err + } + return response, true, nil +} + +// ShouldRouteOpenAIOAuth 判断该账号是否命中当前 OpenAI OAuth 插件绑定。 +// WebSocket 入口用它把命中的账号切换到 HTTP Bridge,避免绕过 v1 HTTP 插件协议。 +func (m *PluginManager) ShouldRouteOpenAIOAuth(account *Account) bool { + if m == nil || account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { + return false + } + route := m.route.Load() + return route != nil && route.rolloutPercent > 0 && int(stablePluginBucket(account.ID)) < route.rolloutPercent +} + +func (m *PluginManager) markRuntimeUnavailable(failedRoute *pluginRoute, message string) error { + m.mu.Lock() + current := m.route.Load() + if current != failedRoute { + m.mu.Unlock() + return nil + } + if failedRoute.runtime != nil { + failedRoute.runtime.kill() + } + delete(m.runtimes, failedRoute.pluginID) + m.route.Store(&pluginRoute{ + pluginID: failedRoute.pluginID, + rolloutPercent: failedRoute.rolloutPercent, + unavailable: message, + }) + m.mu.Unlock() + return nil +} + +func (m *PluginManager) prepareRuntime(ctx context.Context, installation *PluginInstallation, validateConfig bool) (*pluginRuntime, error) { + runtime, err := m.newRuntime(ctx, installation) + if err != nil { + return nil, err + } + configJSON, err := m.decryptConfig(installation) + if err == nil && validateConfig { + applyCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + err = runtime.validateAndApplyConfig(applyCtx, configJSON) + cancel() + } + if err != nil { + runtime.kill() + return nil, err + } + return runtime, nil +} + +func (m *PluginManager) publishRuntimeLocked(installation *PluginInstallation, runtime *pluginRuntime) { + if old := m.runtimes[installation.ID]; old != nil { + old.kill() + } + m.runtimes[installation.ID] = runtime + m.route.Store(&pluginRoute{ + pluginID: installation.ID, + runtime: runtime, + rolloutPercent: bindingRollout(installation.Bindings), + }) +} + +func (m *PluginManager) newRuntime(ctx context.Context, installation *PluginInstallation) (*pluginRuntime, error) { + socketDir := filepath.Join(m.installer.RootDir(), "runtime") + if err := os.MkdirAll(socketDir, 0o700); err != nil { + return nil, err + } + timeout := time.Duration(m.cfg.Plugins.StartTimeoutSeconds) * time.Second + return startPluginRuntime(ctx, installation, timeout, socketDir) +} + +func (m *PluginManager) removeRuntimeLocked(id int64) *pluginRuntime { + runtime := m.runtimes[id] + delete(m.runtimes, id) + if route := m.route.Load(); route != nil && route.pluginID == id { + m.route.Store(nil) + } + return runtime +} + +func (m *PluginManager) decryptConfig(installation *PluginInstallation) (json.RawMessage, error) { + if installation == nil || strings.TrimSpace(installation.ConfigEncrypted) == "" { + return json.RawMessage(`{}`), nil + } + plaintext, err := m.encryptor.Decrypt(installation.ConfigEncrypted) + if err != nil { + return nil, fmt.Errorf("解密插件配置: %w", err) + } + if !json.Valid([]byte(plaintext)) { + return nil, errors.New("已保存的插件配置不是有效 JSON") + } + return json.RawMessage(plaintext), nil +} + +func (m *PluginManager) removeManagedPath(target string) error { + if strings.TrimSpace(target) == "" { + return nil + } + root, err := filepath.Abs(m.installer.RootDir()) + if err != nil { + return err + } + absTarget, err := filepath.Abs(target) + if err != nil { + return err + } + relative, err := filepath.Rel(root, absTarget) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return errors.New("拒绝删除插件根目录之外的路径") + } + return os.RemoveAll(absTarget) +} + +func hasEnabledOpenAIBinding(bindings []PluginBinding) bool { + for _, binding := range bindings { + if binding.Enabled && binding.Capability == PluginCapabilityOpenAIOAuthOutbound && + binding.Platform == PlatformOpenAI && binding.AccountType == AccountTypeOAuth { + return true + } + } + return false +} + +func bindingRollout(bindings []PluginBinding) int { + for _, binding := range bindings { + if binding.Capability == PluginCapabilityOpenAIOAuthOutbound { + return binding.RolloutPercent + } + } + return 100 +} + +func stablePluginBucket(accountID int64) uint64 { + value := uint64(accountID) + value ^= value >> 33 + value *= 0xff51afd7ed558ccd + value ^= value >> 33 + return value % 100 +} diff --git a/backend/internal/service/plugin_manager_routing_test.go b/backend/internal/service/plugin_manager_routing_test.go new file mode 100644 index 000000000..b2f4654cf --- /dev/null +++ b/backend/internal/service/plugin_manager_routing_test.go @@ -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)) + } +} diff --git a/backend/internal/service/plugin_manifest.go b/backend/internal/service/plugin_manifest.go new file mode 100644 index 000000000..b06e3d352 --- /dev/null +++ b/backend/internal/service/plugin_manifest.go @@ -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 +} diff --git a/backend/internal/service/plugin_package.go b/backend/internal/service/plugin_package.go new file mode 100644 index 000000000..2ef9130b0 --- /dev/null +++ b/backend/internal/service/plugin_package.go @@ -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 +} diff --git a/backend/internal/service/plugin_package_test.go b/backend/internal/service/plugin_package_test.go new file mode 100644 index 000000000..cd0e42de1 --- /dev/null +++ b/backend/internal/service/plugin_package_test.go @@ -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(""), + }) + 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(""), + } + } + 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("") + } + 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) +} diff --git a/backend/internal/service/plugin_runtime.go b/backend/internal/service/plugin_runtime.go new file mode 100644 index 000000000..f088f7f03 --- /dev/null +++ b/backend/internal/service/plugin_runtime.go @@ -0,0 +1,394 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os/exec" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" + hclog "github.com/hashicorp/go-hclog" + hcplugin "github.com/hashicorp/go-plugin" +) + +type pluginRuntime struct { + installation *PluginInstallation + client *hcplugin.Client + api pluginv1.TransportPluginClient + inFlight atomic.Int64 + draining atomic.Bool + done chan struct{} + doneOnce sync.Once +} + +func startPluginRuntime(ctx context.Context, installation *PluginInstallation, startTimeout time.Duration, socketDir string) (*pluginRuntime, error) { + if installation == nil { + return nil, errors.New("插件安装记录为空") + } + checksum, err := hex.DecodeString(installation.BinarySHA256) + if err != nil || len(checksum) != sha256.Size { + return nil, errors.New("插件二进制哈希无效") + } + cmd := exec.CommandContext(context.WithoutCancel(ctx), installation.BinaryPath) + client := hcplugin.NewClient(&hcplugin.ClientConfig{ + HandshakeConfig: pluginv1.HandshakeConfig, + Plugins: pluginv1.ClientPluginMap(), + Cmd: cmd, + AllowedProtocols: []hcplugin.Protocol{hcplugin.ProtocolGRPC}, + StartTimeout: startTimeout, + SecureConfig: &hcplugin.SecureConfig{ + Checksum: checksum, + Hash: sha256.New(), + }, + Logger: hclog.NewNullLogger(), + SyncStdout: io.Discard, + SyncStderr: io.Discard, + UnixSocketConfig: &hcplugin.UnixSocketConfig{TempDir: socketDir}, + SkipHostEnv: true, + }) + rpcClient, err := client.Client() + if err != nil { + client.Kill() + return nil, fmt.Errorf("启动插件进程: %w", err) + } + dispensed, err := rpcClient.Dispense(pluginv1.TransportPluginName) + if err != nil { + client.Kill() + return nil, fmt.Errorf("获取插件传输能力: %w", err) + } + api, ok := dispensed.(pluginv1.TransportPluginClient) + if !ok { + client.Kill() + return nil, errors.New("插件未实现传输 gRPC 客户端") + } + runtime := &pluginRuntime{ + installation: installation, + client: client, + api: api, + done: make(chan struct{}), + } + infoCtx, cancel := context.WithTimeout(ctx, startTimeout) + defer cancel() + info, err := api.GetInfo(infoCtx, &pluginv1.GetInfoRequest{}) + if err != nil { + runtime.kill() + return nil, fmt.Errorf("读取插件信息: %w", err) + } + if info.PluginId != installation.PluginKey || info.PluginVersion != installation.Version || + info.ProtocolVersion != pluginv1.ProtocolVersion || info.TransportApiVersion != pluginv1.TransportAPIVersion { + runtime.kill() + return nil, errors.New("插件运行时信息与已校验清单不一致") + } + health, err := api.Health(infoCtx, &pluginv1.HealthRequest{}) + if err != nil || !health.Healthy { + runtime.kill() + if err != nil { + return nil, fmt.Errorf("插件健康检查失败: %w", err) + } + return nil, fmt.Errorf("插件不健康: %s", health.Message) + } + return runtime, nil +} + +func (r *pluginRuntime) validateAndApplyConfig(ctx context.Context, configJSON []byte) error { + _, err := r.validateAndApplyNormalizedConfig(ctx, configJSON) + return err +} + +func (r *pluginRuntime) validateAndApplyNormalizedConfig(ctx context.Context, configJSON []byte) ([]byte, error) { + validation, err := r.api.ValidateConfig(ctx, &pluginv1.ValidateConfigRequest{ConfigJson: configJSON}) + if err != nil { + return nil, fmt.Errorf("插件配置校验失败: %w", err) + } + if !validation.Valid { + return nil, fmt.Errorf("插件配置无效: %s", validation.Message) + } + if len(validation.NormalizedConfigJson) > 0 { + configJSON = validation.NormalizedConfigJson + } + if len(configJSON) == 0 || len(configJSON) > pluginConfigMaxBytes || !json.Valid(configJSON) { + return nil, errors.New("插件返回的规范化配置不是有效且大小受限的 JSON") + } + var normalized any + if err := json.Unmarshal(configJSON, &normalized); err != nil { + return nil, fmt.Errorf("解析插件规范化配置: %w", err) + } + configJSON, err = json.Marshal(normalized) + if err != nil { + return nil, fmt.Errorf("序列化插件规范化配置: %w", err) + } + applied, err := r.api.ApplyConfig(ctx, &pluginv1.ApplyConfigRequest{ConfigJson: configJSON}) + if err != nil { + return nil, fmt.Errorf("应用插件配置失败: %w", err) + } + if !applied.Applied { + return nil, fmt.Errorf("插件拒绝应用配置: %s", applied.Message) + } + return configJSON, nil +} + +func (r *pluginRuntime) beginRequest() bool { + if r == nil || r.draining.Load() { + return false + } + r.inFlight.Add(1) + if r.draining.Load() { + r.finishRequest() + return false + } + return true +} + +func (r *pluginRuntime) finishRequest() { + if r.inFlight.Add(-1) == 0 && r.draining.Load() { + r.doneOnce.Do(func() { close(r.done) }) + } +} + +func (r *pluginRuntime) drain(timeout time.Duration) { + if r == nil { + return + } + r.draining.Store(true) + if r.inFlight.Load() == 0 { + r.doneOnce.Do(func() { close(r.done) }) + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-r.done: + case <-timer.C: + } + r.kill() +} + +func (r *pluginRuntime) kill() { + if r != nil && r.client != nil { + r.client.Kill() + } +} + +func (r *pluginRuntime) roundTrip(ctx context.Context, request *http.Request, proxyURL string, account *Account) (*http.Response, error) { + if request == nil || request.URL == nil || account == nil { + return nil, errors.New("插件出站请求参数不完整") + } + streamCtx, cancel := context.WithCancel(ctx) + stream, err := r.api.Forward(streamCtx) + if err != nil { + cancel() + return nil, normalizePluginRPCError(ctx, "创建插件转发流", err, false) + } + requestID := strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.FormatInt(account.ID, 36) + if err := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_Start{Start: &pluginv1.ForwardRequestStart{ + RequestId: requestID, + Method: request.Method, + Url: request.URL.String(), + Host: request.Host, + Headers: headersToPlugin(request.Header), + ProxyUrl: proxyURL, + AccountId: account.ID, + AccountConcurrency: int32(account.Concurrency), + Platform: account.Platform, + AccountType: account.Type, + ContentLength: request.ContentLength, + HasBody: request.Body != nil && request.Body != http.NoBody, + }}}); err != nil { + cancel() + // gRPC Send 返回错误时无法证明服务端没有收到元数据,必须禁止自动重放。 + return nil, normalizePluginRPCError(ctx, "发送插件请求元数据", err, true) + } + sendErr := make(chan error, 1) + go func() { + err := sendPluginRequestBody(stream, request.Body) + sendErr <- err + if err != nil { + cancel() + } + }() + + first, err := stream.Recv() + if err != nil { + cancel() + select { + case bodyErr := <-sendErr: + if bodyErr != nil { + return nil, normalizePluginRPCError(ctx, "发送插件请求体", bodyErr, true) + } + default: + } + return nil, normalizePluginRPCError(ctx, "接收插件响应头", err, true) + } + if frameError := first.GetError(); frameError != nil { + cancel() + return nil, &PluginTransportError{Code: frameError.Code, Message: frameError.Message, RequestSent: frameError.RequestSent} + } + start := first.GetStart() + if start == nil || start.StatusCode < 100 || start.StatusCode > 599 { + cancel() + return nil, &PluginTransportError{ + Code: "PLUGIN_INVALID_RESPONSE", + Message: "插件未返回有效的 HTTP 响应头", + RequestSent: true, + } + } + pipeReader, pipeWriter := io.Pipe() + body := &pluginResponseBody{ + reader: pipeReader, + cancel: cancel, + done: r.finishRequest, + } + go receivePluginResponseBody(stream, pipeWriter, sendErr) + return &http.Response{ + Status: start.Status, + StatusCode: int(start.StatusCode), + Proto: start.Protocol, + ProtoMajor: int(start.ProtocolMajor), + ProtoMinor: int(start.ProtocolMinor), + Header: headersFromPlugin(start.Headers), + Body: body, + ContentLength: start.ContentLength, + Request: request, + }, nil +} + +type PluginTransportError struct { + Code string + Message string + RequestSent bool +} + +func (e *PluginTransportError) Error() string { + if e == nil { + return "插件传输失败" + } + code := strings.Map(func(value rune) rune { + if value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' || value == '_' || value == '-' || value == '.' { + return value + } + return -1 + }, e.Code) + if len(code) > 64 { + code = code[:64] + } + return fmt.Sprintf("插件传输失败 [%s]: %s", code, sanitizeUpstreamErrorMessage(e.Message)) +} + +func normalizePluginRPCError(ctx context.Context, operation string, err error, requestMayHaveBeenSent bool) error { + if ctx != nil && ctx.Err() != nil { + return ctx.Err() + } + return &PluginTransportError{ + Code: "PLUGIN_RPC_ERROR", + Message: fmt.Sprintf("%s: %v", operation, err), + RequestSent: requestMayHaveBeenSent, + } +} + +func sendPluginRequestBody(stream pluginv1.TransportPlugin_ForwardClient, body io.ReadCloser) error { + if body != nil { + defer func() { _ = body.Close() }() + buffer := make([]byte, 32*1024) + for { + read, err := body.Read(buffer) + if read > 0 { + chunk := append([]byte(nil), buffer[:read]...) + if sendErr := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_BodyChunk{BodyChunk: chunk}}); sendErr != nil { + return sendErr + } + } + if err == io.EOF { + break + } + if err != nil { + return err + } + } + } + if err := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_BodyEnd{BodyEnd: true}}); err != nil { + return err + } + return stream.CloseSend() +} + +func receivePluginResponseBody(stream pluginv1.TransportPlugin_ForwardClient, writer *io.PipeWriter, sendErr <-chan error) { + defer func() { _ = writer.Close() }() + for { + frame, err := stream.Recv() + if err == io.EOF { + return + } + if err != nil { + _ = writer.CloseWithError(normalizePluginRPCError(stream.Context(), "接收插件响应体", err, true)) + return + } + if chunk := frame.GetBodyChunk(); len(chunk) > 0 { + if _, err := writer.Write(chunk); err != nil { + return + } + continue + } + if frame.GetEnd() != nil { + select { + case err := <-sendErr: + if err != nil { + _ = writer.CloseWithError(normalizePluginRPCError(stream.Context(), "发送插件请求体", err, true)) + } + default: + } + return + } + if frameError := frame.GetError(); frameError != nil { + _ = writer.CloseWithError(&PluginTransportError{Code: frameError.Code, Message: frameError.Message, RequestSent: frameError.RequestSent}) + return + } + } +} + +type pluginResponseBody struct { + reader *io.PipeReader + cancel context.CancelFunc + done func() + once sync.Once +} + +func (b *pluginResponseBody) Read(data []byte) (int, error) { + return b.reader.Read(data) +} + +func (b *pluginResponseBody) Close() error { + var err error + b.once.Do(func() { + b.cancel() + err = b.reader.Close() + b.done() + }) + return err +} + +func headersToPlugin(headers http.Header) map[string]*pluginv1.HeaderValues { + out := make(map[string]*pluginv1.HeaderValues, len(headers)) + for key, values := range headers { + out[key] = &pluginv1.HeaderValues{Values: append([]string(nil), values...)} + } + return out +} + +func headersFromPlugin(headers map[string]*pluginv1.HeaderValues) http.Header { + out := make(http.Header, len(headers)) + for key, values := range headers { + if values != nil { + out[key] = append([]string(nil), values.Values...) + } + } + return out +} diff --git a/backend/internal/service/plugin_runtime_integration_test.go b/backend/internal/service/plugin_runtime_integration_test.go new file mode 100644 index 000000000..42db798d3 --- /dev/null +++ b/backend/internal/service/plugin_runtime_integration_test.go @@ -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) +} diff --git a/backend/internal/service/plugin_security_regression_test.go b/backend/internal/service/plugin_security_regression_test.go new file mode 100644 index 000000000..ae45fe442 --- /dev/null +++ b/backend/internal/service/plugin_security_regression_test.go @@ -0,0 +1,239 @@ +package service + +import ( + "context" + "encoding/base64" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +type pluginTokenRepository struct { + PluginRepository + installation *PluginInstallation + listErr error +} + +func (r *pluginTokenRepository) List(context.Context) ([]*PluginInstallation, error) { + if r.listErr != nil { + return nil, r.listErr + } + if r.installation == nil { + return nil, nil + } + copy := *r.installation + return []*PluginInstallation{©}, nil +} + +func (r *pluginTokenRepository) GetByID(context.Context, int64) (*PluginInstallation, error) { + if r.installation == nil { + return nil, errors.New("插件不存在") + } + copy := *r.installation + return ©, nil +} + +type pluginTokenEncryptor struct{} + +func (pluginTokenEncryptor) Encrypt(plaintext string) (string, error) { + return "ENC:" + plaintext, nil +} + +func (pluginTokenEncryptor) Decrypt(ciphertext string) (string, error) { + if !strings.HasPrefix(ciphertext, "ENC:") { + return "", errors.New("密文无效") + } + return strings.TrimPrefix(ciphertext, "ENC:"), nil +} + +func TestPluginUIAssetTokenCanBeResolvedByAnotherInstance(t *testing.T) { + repo := &pluginTokenRepository{installation: &PluginInstallation{ID: 42}} + first := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}} + second := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}} + + token, expires, err := first.CreateUIAssetToken(context.Background(), 42, 30*time.Minute) + require.NoError(t, err) + require.WithinDuration(t, time.Now().Add(30*time.Minute), expires, time.Second) + + pluginID, err := second.ResolveUIAssetToken(token) + require.NoError(t, err) + require.Equal(t, int64(42), pluginID) + + decoded, err := base64.RawURLEncoding.DecodeString(token) + require.NoError(t, err) + decoded[len(decoded)-1] ^= 1 + _, err = second.ResolveUIAssetToken(base64.RawURLEncoding.EncodeToString(decoded)) + require.Error(t, err) +} + +func TestPluginUIAssetTokenRejectsOtherEncryptedPayloads(t *testing.T) { + repo := &pluginTokenRepository{installation: &PluginInstallation{ID: 42}} + manager := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}} + + encrypted, err := manager.encryptor.Encrypt(`{"version":1,"plugin_id":42,"expires":4102444800}`) + require.NoError(t, err) + token := base64.RawURLEncoding.EncodeToString([]byte(encrypted)) + + _, err = manager.ResolveUIAssetToken(token) + require.ErrorContains(t, err, "会话无效") +} + +func TestPluginReconcileFailsClosedWhenDesiredStateCannotBeRead(t *testing.T) { + manager := &PluginManager{ + repo: &pluginTokenRepository{listErr: errors.New("数据库不可用")}, + runtimes: make(map[int64]*pluginRuntime), + localInstallations: make(map[int64]*PluginInstallation), + } + + err := manager.reconcileOnce(context.Background()) + require.ErrorContains(t, err, "读取插件启用状态") + require.True(t, manager.ShouldRouteOpenAIOAuth(&Account{ + ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + })) + + request, requestErr := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil) + require.NoError(t, requestErr) + _, handled, routeErr := manager.RoundTripOpenAIOAuth(context.Background(), request, "", &Account{ + ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + }) + require.True(t, handled) + require.ErrorContains(t, routeErr, "插件不可用") +} + +type normalizingPluginClient struct { + pluginv1.TransportPluginClient + normalized []byte + applied []byte +} + +type pluginConfigRepository struct { + PluginRepository + installation *PluginInstallation + encrypted string +} + +func (r *pluginConfigRepository) GetByID(context.Context, int64) (*PluginInstallation, error) { + copy := *r.installation + return ©, nil +} + +func (r *pluginConfigRepository) UpdateConfig(_ context.Context, _ int64, encrypted, expectedBinarySHA256 string) error { + if expectedBinarySHA256 != r.installation.BinarySHA256 { + return ErrPluginStateChanged + } + r.encrypted = encrypted + return nil +} + +func (c *normalizingPluginClient) ValidateConfig(context.Context, *pluginv1.ValidateConfigRequest, ...grpc.CallOption) (*pluginv1.ValidateConfigResponse, error) { + return &pluginv1.ValidateConfigResponse{Valid: true, NormalizedConfigJson: c.normalized}, nil +} + +func (c *normalizingPluginClient) ApplyConfig(_ context.Context, request *pluginv1.ApplyConfigRequest, _ ...grpc.CallOption) (*pluginv1.ApplyConfigResponse, error) { + c.applied = append([]byte(nil), request.ConfigJson...) + return &pluginv1.ApplyConfigResponse{Applied: true}, nil +} + +func TestPluginRuntimeReturnsAndAppliesNormalizedConfig(t *testing.T) { + client := &normalizingPluginClient{normalized: []byte(`{"z":2,"a":1}`)} + runtime := &pluginRuntime{api: client} + + normalized, err := runtime.validateAndApplyNormalizedConfig(context.Background(), []byte(`{"input":true}`)) + require.NoError(t, err) + require.JSONEq(t, `{"a":1,"z":2}`, string(normalized)) + require.Equal(t, normalized, client.applied) +} + +func TestPluginRuntimeRejectsInvalidNormalizedConfig(t *testing.T) { + client := &normalizingPluginClient{normalized: []byte(`{"broken"`)} + runtime := &pluginRuntime{api: client} + + _, err := runtime.validateAndApplyNormalizedConfig(context.Background(), []byte(`{}`)) + require.ErrorContains(t, err, "规范化配置") + require.Empty(t, client.applied) +} + +func TestPluginManagerPersistsPluginNormalizedConfig(t *testing.T) { + installation := &PluginInstallation{ID: 9, BinarySHA256: strings.Repeat("a", 64)} + repo := &pluginConfigRepository{installation: installation} + client := &normalizingPluginClient{normalized: []byte(`{"timeout":30,"enabled":true}`)} + manager := &PluginManager{ + repo: repo, encryptor: pluginTokenEncryptor{}, + runtimes: map[int64]*pluginRuntime{9: {installation: installation, api: client}}, + } + + saved, err := manager.SaveConfig(context.Background(), 9, []byte(`{"enabled":false}`)) + require.NoError(t, err) + require.JSONEq(t, `{"enabled":true,"timeout":30}`, string(saved)) + plaintext, err := (pluginTokenEncryptor{}).Decrypt(repo.encrypted) + require.NoError(t, err) + require.JSONEq(t, string(saved), plaintext) +} + +func TestPluginRequestSentErrorDoesNotFailOver(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil) + account := &Account{ID: 7, Name: "oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth} + transportErr := &PluginTransportError{Code: "UPSTREAM_EOF", Message: "eof", RequestSent: true} + + result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, transportErr, true) + + require.Same(t, transportErr, result) + var failover *UpstreamFailoverError + require.False(t, errors.As(result, &failover)) +} + +func TestPluginRPCAmbiguityPreventsReplayAfterMetadataDelivery(t *testing.T) { + err := normalizePluginRPCError(context.Background(), "接收插件响应头", errors.New("连接已断开"), true) + var transportErr *PluginTransportError + require.ErrorAs(t, err, &transportErr) + require.True(t, transportErr.RequestSent) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil) + account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, err, true) + require.Same(t, err, result) +} + +func TestPluginRPCFailureBeforeStreamCreationAllowsFailover(t *testing.T) { + err := normalizePluginRPCError(context.Background(), "创建插件转发流", errors.New("连接失败"), false) + var transportErr *PluginTransportError + require.ErrorAs(t, err, &transportErr) + require.False(t, transportErr.RequestSent) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil) + account := &Account{ID: 13, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, err, true) + var failover *UpstreamFailoverError + require.ErrorAs(t, result, &failover) +} + +func TestNormalizePluginRPCErrorPreservesCallerCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := normalizePluginRPCError(ctx, "接收响应", errors.New("rpc error: code = Canceled"), true) + require.ErrorIs(t, err, context.Canceled) +} + +func TestPluginStartingStateUsesBoundedCrashRecoveryWindow(t *testing.T) { + manager := &PluginManager{cfg: &config.Config{Plugins: config.PluginConfig{StartTimeoutSeconds: 15}}} + + require.False(t, manager.startingStateExpired(&PluginInstallation{UpdatedAt: time.Now().Add(-30 * time.Second)})) + require.True(t, manager.startingStateExpired(&PluginInstallation{UpdatedAt: time.Now().Add(-2 * time.Minute)})) +} diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 7787e8810..a1a0c218e 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -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" diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index 81d74aff0..7072dd7c0 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -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, diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 65e28ff6e..7a421fde7 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -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) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 25d48e7dc..25d2b313e 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -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"` diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 4c34a62e3..3e469a95c 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -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, diff --git a/backend/migrations/229_plugins.sql b/backend/migrations/229_plugins.sql new file mode 100644 index 000000000..247fb7a93 --- /dev/null +++ b/backend/migrations/229_plugins.sql @@ -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; diff --git a/backend/migrations/230_plugin_artifacts.sql b/backend/migrations/230_plugin_artifacts.sql new file mode 100644 index 000000000..c29650285 --- /dev/null +++ b/backend/migrations/230_plugin_artifacts.sql @@ -0,0 +1,4 @@ +-- 保存经过签名校验的原始插件包,供多实例和无状态节点重新复验、解包。 +-- 旧安装记录允许暂时为空;管理员重新上传该插件后会自动补齐。 +ALTER TABLE sub2api_plugin_installations + ADD COLUMN IF NOT EXISTS artifact_data BYTEA; diff --git a/backend/migrations/plugins_migration_test.go b/backend/migrations/plugins_migration_test.go new file mode 100644 index 000000000..10a1493bc --- /dev/null +++ b/backend/migrations/plugins_migration_test.go @@ -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") +} diff --git a/backend/pkg/pluginapi/README.md b/backend/pkg/pluginapi/README.md new file mode 100644 index 000000000..b8f8b0eff --- /dev/null +++ b/backend/pkg/pluginapi/README.md @@ -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`,宿主以 `.result` 返回结果。配置整体使用 Sub2API 的密钥加密后存入数据库;运行中插件会先验证并应用新配置,数据库写入失败时恢复旧配置。 + +## 协议源码 + +- `v1/plugin.proto`:稳定的进程间消息定义。 +- `v1/runtime.go`:Go 插件进程启动入口和宿主客户端声明。 +- `v1/manifest.schema.json`:`manifest.json` 的 JSON Schema。 + +插件通过进程协议协作,不使用 Go 动态链接,也不要求插件与 Sub2API 使用相同编译器或共享内存 ABI。 diff --git a/backend/pkg/pluginapi/docs/development.md b/backend/pkg/pluginapi/docs/development.md new file mode 100644 index 000000000..65d9d0f41 --- /dev/null +++ b/backend/pkg/pluginapi/docs/development.md @@ -0,0 +1,67 @@ +# 插件开发指南 + +## 稳定边界 + +当前宿主只支持 `openai.oauth.outbound_transport.v1`。插件负责建立实际上游 HTTP/TLS 连接,Sub2API 负责账号选择、OAuth Token 生命周期、下游协议、响应解析、SSE、错误映射、用量统计和计费。 + +插件不应修改 API Key 路径,也不应自行刷新或持久化 OAuth Token。 + +## 推荐结构 + +```text +plugin/ +├── cmd//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 加载、保存、测试、错误和超时。 + +发布前还应使用真实构建包运行宿主的插件进程集成测试。 diff --git a/backend/pkg/pluginapi/docs/package-format.md b/backend/pkg/pluginapi/docs/package-format.md new file mode 100644 index 000000000..750760808 --- /dev/null +++ b/backend/pkg/pluginapi/docs/package-format.md @@ -0,0 +1,45 @@ +# `.s2plugin` 包格式 + +`.s2plugin` 是 ZIP 文件,根目录必须包含 `manifest.json`,生产包还必须包含 `signature.json`。 + +## 标准布局 + +```text +manifest.json +signature.json +runtimes/-/ +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` 只用于开发者自己构建的本地包。 diff --git a/backend/pkg/pluginapi/docs/security.md b/backend/pkg/pluginapi/docs/security.md new file mode 100644 index 000000000..1cad27ac6 --- /dev/null +++ b/backend/pkg/pluginapi/docs/security.md @@ -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。 diff --git a/backend/pkg/pluginapi/docs/ui-bridge.md b/backend/pkg/pluginapi/docs/ui-bridge.md new file mode 100644 index 000000000..9f39fd0c6 --- /dev/null +++ b/backend/pkg/pluginapi/docs/ui-bridge.md @@ -0,0 +1,56 @@ +# UI Bridge v1 + +## 加载方式 + +宿主为每次打开配置页创建短时 UI 会话: + +```text +/api/v1/plugin-ui//index.html#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 或浏览器存储中的宿主数据。 diff --git a/backend/pkg/pluginapi/v1/manifest.schema.json b/backend/pkg/pluginapi/v1/manifest.schema.json new file mode 100644 index 000000000..e23780ff8 --- /dev/null +++ b/backend/pkg/pluginapi/v1/manifest.schema.json @@ -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": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$" + } + } +} diff --git a/backend/pkg/pluginapi/v1/manifest_schema_test.go b/backend/pkg/pluginapi/v1/manifest_schema_test.go new file mode 100644 index 000000000..4805e5300 --- /dev/null +++ b/backend/pkg/pluginapi/v1/manifest_schema_test.go @@ -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"]) + } +} diff --git a/backend/pkg/pluginapi/v1/plugin.pb.go b/backend/pkg/pluginapi/v1/plugin.pb.go new file mode 100644 index 000000000..86c6a8f66 --- /dev/null +++ b/backend/pkg/pluginapi/v1/plugin.pb.go @@ -0,0 +1,1315 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v3.12.4 +// source: plugin.proto + +package pluginv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetInfoRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInfoRequest) Reset() { + *x = GetInfoRequest{} + mi := &file_plugin_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInfoRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInfoRequest) ProtoMessage() {} + +func (x *GetInfoRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInfoRequest.ProtoReflect.Descriptor instead. +func (*GetInfoRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{0} +} + +type GetInfoResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + PluginId string `protobuf:"bytes,1,opt,name=plugin_id,json=pluginId,proto3" json:"plugin_id,omitempty"` + PluginVersion string `protobuf:"bytes,2,opt,name=plugin_version,json=pluginVersion,proto3" json:"plugin_version,omitempty"` + ProtocolVersion uint32 `protobuf:"varint,3,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` + TransportApiVersion uint32 `protobuf:"varint,4,opt,name=transport_api_version,json=transportApiVersion,proto3" json:"transport_api_version,omitempty"` + Capabilities []string `protobuf:"bytes,5,rep,name=capabilities,proto3" json:"capabilities,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInfoResponse) Reset() { + *x = GetInfoResponse{} + mi := &file_plugin_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInfoResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInfoResponse) ProtoMessage() {} + +func (x *GetInfoResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInfoResponse.ProtoReflect.Descriptor instead. +func (*GetInfoResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{1} +} + +func (x *GetInfoResponse) GetPluginId() string { + if x != nil { + return x.PluginId + } + return "" +} + +func (x *GetInfoResponse) GetPluginVersion() string { + if x != nil { + return x.PluginVersion + } + return "" +} + +func (x *GetInfoResponse) GetProtocolVersion() uint32 { + if x != nil { + return x.ProtocolVersion + } + return 0 +} + +func (x *GetInfoResponse) GetTransportApiVersion() uint32 { + if x != nil { + return x.TransportApiVersion + } + return 0 +} + +func (x *GetInfoResponse) GetCapabilities() []string { + if x != nil { + return x.Capabilities + } + return nil +} + +type HealthRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthRequest) Reset() { + *x = HealthRequest{} + mi := &file_plugin_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthRequest) ProtoMessage() {} + +func (x *HealthRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead. +func (*HealthRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{2} +} + +type HealthResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Healthy bool `protobuf:"varint,1,opt,name=healthy,proto3" json:"healthy,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthResponse) Reset() { + *x = HealthResponse{} + mi := &file_plugin_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthResponse) ProtoMessage() {} + +func (x *HealthResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead. +func (*HealthResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{3} +} + +func (x *HealthResponse) GetHealthy() bool { + if x != nil { + return x.Healthy + } + return false +} + +func (x *HealthResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type ValidateConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConfigJson []byte `protobuf:"bytes,1,opt,name=config_json,json=configJson,proto3" json:"config_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ValidateConfigRequest) Reset() { + *x = ValidateConfigRequest{} + mi := &file_plugin_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ValidateConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidateConfigRequest) ProtoMessage() {} + +func (x *ValidateConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidateConfigRequest.ProtoReflect.Descriptor instead. +func (*ValidateConfigRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{4} +} + +func (x *ValidateConfigRequest) GetConfigJson() []byte { + if x != nil { + return x.ConfigJson + } + return nil +} + +type ValidateConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + NormalizedConfigJson []byte `protobuf:"bytes,3,opt,name=normalized_config_json,json=normalizedConfigJson,proto3" json:"normalized_config_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ValidateConfigResponse) Reset() { + *x = ValidateConfigResponse{} + mi := &file_plugin_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ValidateConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidateConfigResponse) ProtoMessage() {} + +func (x *ValidateConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidateConfigResponse.ProtoReflect.Descriptor instead. +func (*ValidateConfigResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{5} +} + +func (x *ValidateConfigResponse) GetValid() bool { + if x != nil { + return x.Valid + } + return false +} + +func (x *ValidateConfigResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ValidateConfigResponse) GetNormalizedConfigJson() []byte { + if x != nil { + return x.NormalizedConfigJson + } + return nil +} + +type ApplyConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConfigJson []byte `protobuf:"bytes,1,opt,name=config_json,json=configJson,proto3" json:"config_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApplyConfigRequest) Reset() { + *x = ApplyConfigRequest{} + mi := &file_plugin_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApplyConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApplyConfigRequest) ProtoMessage() {} + +func (x *ApplyConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApplyConfigRequest.ProtoReflect.Descriptor instead. +func (*ApplyConfigRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{6} +} + +func (x *ApplyConfigRequest) GetConfigJson() []byte { + if x != nil { + return x.ConfigJson + } + return nil +} + +type ApplyConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Applied bool `protobuf:"varint,1,opt,name=applied,proto3" json:"applied,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApplyConfigResponse) Reset() { + *x = ApplyConfigResponse{} + mi := &file_plugin_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApplyConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApplyConfigResponse) ProtoMessage() {} + +func (x *ApplyConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApplyConfigResponse.ProtoReflect.Descriptor instead. +func (*ApplyConfigResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{7} +} + +func (x *ApplyConfigResponse) GetApplied() bool { + if x != nil { + return x.Applied + } + return false +} + +func (x *ApplyConfigResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type TestConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConfigJson []byte `protobuf:"bytes,1,opt,name=config_json,json=configJson,proto3" json:"config_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TestConfigRequest) Reset() { + *x = TestConfigRequest{} + mi := &file_plugin_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TestConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestConfigRequest) ProtoMessage() {} + +func (x *TestConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TestConfigRequest.ProtoReflect.Descriptor instead. +func (*TestConfigRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{8} +} + +func (x *TestConfigRequest) GetConfigJson() []byte { + if x != nil { + return x.ConfigJson + } + return nil +} + +type TestConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + LatencyMs int64 `protobuf:"varint,3,opt,name=latency_ms,json=latencyMs,proto3" json:"latency_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TestConfigResponse) Reset() { + *x = TestConfigResponse{} + mi := &file_plugin_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TestConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestConfigResponse) ProtoMessage() {} + +func (x *TestConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TestConfigResponse.ProtoReflect.Descriptor instead. +func (*TestConfigResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{9} +} + +func (x *TestConfigResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *TestConfigResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *TestConfigResponse) GetLatencyMs() int64 { + if x != nil { + return x.LatencyMs + } + return 0 +} + +type HeaderValues struct { + state protoimpl.MessageState `protogen:"open.v1"` + Values []string `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HeaderValues) Reset() { + *x = HeaderValues{} + mi := &file_plugin_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeaderValues) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeaderValues) ProtoMessage() {} + +func (x *HeaderValues) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeaderValues.ProtoReflect.Descriptor instead. +func (*HeaderValues) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{10} +} + +func (x *HeaderValues) GetValues() []string { + if x != nil { + return x.Values + } + return nil +} + +type ForwardRequestStart struct { + state protoimpl.MessageState `protogen:"open.v1"` + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + Method string `protobuf:"bytes,2,opt,name=method,proto3" json:"method,omitempty"` + Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"` + Host string `protobuf:"bytes,4,opt,name=host,proto3" json:"host,omitempty"` + Headers map[string]*HeaderValues `protobuf:"bytes,5,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + ProxyUrl string `protobuf:"bytes,6,opt,name=proxy_url,json=proxyUrl,proto3" json:"proxy_url,omitempty"` + AccountId int64 `protobuf:"varint,7,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` + AccountConcurrency int32 `protobuf:"varint,8,opt,name=account_concurrency,json=accountConcurrency,proto3" json:"account_concurrency,omitempty"` + Platform string `protobuf:"bytes,9,opt,name=platform,proto3" json:"platform,omitempty"` + AccountType string `protobuf:"bytes,10,opt,name=account_type,json=accountType,proto3" json:"account_type,omitempty"` + ContentLength int64 `protobuf:"varint,11,opt,name=content_length,json=contentLength,proto3" json:"content_length,omitempty"` + HasBody bool `protobuf:"varint,12,opt,name=has_body,json=hasBody,proto3" json:"has_body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardRequestStart) Reset() { + *x = ForwardRequestStart{} + mi := &file_plugin_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardRequestStart) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardRequestStart) ProtoMessage() {} + +func (x *ForwardRequestStart) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardRequestStart.ProtoReflect.Descriptor instead. +func (*ForwardRequestStart) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{11} +} + +func (x *ForwardRequestStart) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *ForwardRequestStart) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *ForwardRequestStart) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *ForwardRequestStart) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *ForwardRequestStart) GetHeaders() map[string]*HeaderValues { + if x != nil { + return x.Headers + } + return nil +} + +func (x *ForwardRequestStart) GetProxyUrl() string { + if x != nil { + return x.ProxyUrl + } + return "" +} + +func (x *ForwardRequestStart) GetAccountId() int64 { + if x != nil { + return x.AccountId + } + return 0 +} + +func (x *ForwardRequestStart) GetAccountConcurrency() int32 { + if x != nil { + return x.AccountConcurrency + } + return 0 +} + +func (x *ForwardRequestStart) GetPlatform() string { + if x != nil { + return x.Platform + } + return "" +} + +func (x *ForwardRequestStart) GetAccountType() string { + if x != nil { + return x.AccountType + } + return "" +} + +func (x *ForwardRequestStart) GetContentLength() int64 { + if x != nil { + return x.ContentLength + } + return 0 +} + +func (x *ForwardRequestStart) GetHasBody() bool { + if x != nil { + return x.HasBody + } + return false +} + +type ForwardRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Frame: + // + // *ForwardRequest_Start + // *ForwardRequest_BodyChunk + // *ForwardRequest_BodyEnd + Frame isForwardRequest_Frame `protobuf_oneof:"frame"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardRequest) Reset() { + *x = ForwardRequest{} + mi := &file_plugin_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardRequest) ProtoMessage() {} + +func (x *ForwardRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardRequest.ProtoReflect.Descriptor instead. +func (*ForwardRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{12} +} + +func (x *ForwardRequest) GetFrame() isForwardRequest_Frame { + if x != nil { + return x.Frame + } + return nil +} + +func (x *ForwardRequest) GetStart() *ForwardRequestStart { + if x != nil { + if x, ok := x.Frame.(*ForwardRequest_Start); ok { + return x.Start + } + } + return nil +} + +func (x *ForwardRequest) GetBodyChunk() []byte { + if x != nil { + if x, ok := x.Frame.(*ForwardRequest_BodyChunk); ok { + return x.BodyChunk + } + } + return nil +} + +func (x *ForwardRequest) GetBodyEnd() bool { + if x != nil { + if x, ok := x.Frame.(*ForwardRequest_BodyEnd); ok { + return x.BodyEnd + } + } + return false +} + +type isForwardRequest_Frame interface { + isForwardRequest_Frame() +} + +type ForwardRequest_Start struct { + Start *ForwardRequestStart `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ForwardRequest_BodyChunk struct { + BodyChunk []byte `protobuf:"bytes,2,opt,name=body_chunk,json=bodyChunk,proto3,oneof"` +} + +type ForwardRequest_BodyEnd struct { + BodyEnd bool `protobuf:"varint,3,opt,name=body_end,json=bodyEnd,proto3,oneof"` +} + +func (*ForwardRequest_Start) isForwardRequest_Frame() {} + +func (*ForwardRequest_BodyChunk) isForwardRequest_Frame() {} + +func (*ForwardRequest_BodyEnd) isForwardRequest_Frame() {} + +type ForwardResponseStart struct { + state protoimpl.MessageState `protogen:"open.v1"` + StatusCode int32 `protobuf:"varint,1,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + Protocol string `protobuf:"bytes,3,opt,name=protocol,proto3" json:"protocol,omitempty"` + ProtocolMajor int32 `protobuf:"varint,4,opt,name=protocol_major,json=protocolMajor,proto3" json:"protocol_major,omitempty"` + ProtocolMinor int32 `protobuf:"varint,5,opt,name=protocol_minor,json=protocolMinor,proto3" json:"protocol_minor,omitempty"` + Headers map[string]*HeaderValues `protobuf:"bytes,6,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + ContentLength int64 `protobuf:"varint,7,opt,name=content_length,json=contentLength,proto3" json:"content_length,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardResponseStart) Reset() { + *x = ForwardResponseStart{} + mi := &file_plugin_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardResponseStart) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardResponseStart) ProtoMessage() {} + +func (x *ForwardResponseStart) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardResponseStart.ProtoReflect.Descriptor instead. +func (*ForwardResponseStart) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{13} +} + +func (x *ForwardResponseStart) GetStatusCode() int32 { + if x != nil { + return x.StatusCode + } + return 0 +} + +func (x *ForwardResponseStart) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ForwardResponseStart) GetProtocol() string { + if x != nil { + return x.Protocol + } + return "" +} + +func (x *ForwardResponseStart) GetProtocolMajor() int32 { + if x != nil { + return x.ProtocolMajor + } + return 0 +} + +func (x *ForwardResponseStart) GetProtocolMinor() int32 { + if x != nil { + return x.ProtocolMinor + } + return 0 +} + +func (x *ForwardResponseStart) GetHeaders() map[string]*HeaderValues { + if x != nil { + return x.Headers + } + return nil +} + +func (x *ForwardResponseStart) GetContentLength() int64 { + if x != nil { + return x.ContentLength + } + return 0 +} + +type ForwardResponseEnd struct { + state protoimpl.MessageState `protogen:"open.v1"` + BytesReceived int64 `protobuf:"varint,1,opt,name=bytes_received,json=bytesReceived,proto3" json:"bytes_received,omitempty"` + DurationMs int64 `protobuf:"varint,2,opt,name=duration_ms,json=durationMs,proto3" json:"duration_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardResponseEnd) Reset() { + *x = ForwardResponseEnd{} + mi := &file_plugin_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardResponseEnd) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardResponseEnd) ProtoMessage() {} + +func (x *ForwardResponseEnd) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardResponseEnd.ProtoReflect.Descriptor instead. +func (*ForwardResponseEnd) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{14} +} + +func (x *ForwardResponseEnd) GetBytesReceived() int64 { + if x != nil { + return x.BytesReceived + } + return 0 +} + +func (x *ForwardResponseEnd) GetDurationMs() int64 { + if x != nil { + return x.DurationMs + } + return 0 +} + +type ForwardResponseError struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + RequestSent bool `protobuf:"varint,3,opt,name=request_sent,json=requestSent,proto3" json:"request_sent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardResponseError) Reset() { + *x = ForwardResponseError{} + mi := &file_plugin_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardResponseError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardResponseError) ProtoMessage() {} + +func (x *ForwardResponseError) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardResponseError.ProtoReflect.Descriptor instead. +func (*ForwardResponseError) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{15} +} + +func (x *ForwardResponseError) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *ForwardResponseError) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ForwardResponseError) GetRequestSent() bool { + if x != nil { + return x.RequestSent + } + return false +} + +type ForwardResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Frame: + // + // *ForwardResponse_Start + // *ForwardResponse_BodyChunk + // *ForwardResponse_End + // *ForwardResponse_Error + Frame isForwardResponse_Frame `protobuf_oneof:"frame"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardResponse) Reset() { + *x = ForwardResponse{} + mi := &file_plugin_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardResponse) ProtoMessage() {} + +func (x *ForwardResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardResponse.ProtoReflect.Descriptor instead. +func (*ForwardResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{16} +} + +func (x *ForwardResponse) GetFrame() isForwardResponse_Frame { + if x != nil { + return x.Frame + } + return nil +} + +func (x *ForwardResponse) GetStart() *ForwardResponseStart { + if x != nil { + if x, ok := x.Frame.(*ForwardResponse_Start); ok { + return x.Start + } + } + return nil +} + +func (x *ForwardResponse) GetBodyChunk() []byte { + if x != nil { + if x, ok := x.Frame.(*ForwardResponse_BodyChunk); ok { + return x.BodyChunk + } + } + return nil +} + +func (x *ForwardResponse) GetEnd() *ForwardResponseEnd { + if x != nil { + if x, ok := x.Frame.(*ForwardResponse_End); ok { + return x.End + } + } + return nil +} + +func (x *ForwardResponse) GetError() *ForwardResponseError { + if x != nil { + if x, ok := x.Frame.(*ForwardResponse_Error); ok { + return x.Error + } + } + return nil +} + +type isForwardResponse_Frame interface { + isForwardResponse_Frame() +} + +type ForwardResponse_Start struct { + Start *ForwardResponseStart `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ForwardResponse_BodyChunk struct { + BodyChunk []byte `protobuf:"bytes,2,opt,name=body_chunk,json=bodyChunk,proto3,oneof"` +} + +type ForwardResponse_End struct { + End *ForwardResponseEnd `protobuf:"bytes,3,opt,name=end,proto3,oneof"` +} + +type ForwardResponse_Error struct { + Error *ForwardResponseError `protobuf:"bytes,4,opt,name=error,proto3,oneof"` +} + +func (*ForwardResponse_Start) isForwardResponse_Frame() {} + +func (*ForwardResponse_BodyChunk) isForwardResponse_Frame() {} + +func (*ForwardResponse_End) isForwardResponse_Frame() {} + +func (*ForwardResponse_Error) isForwardResponse_Frame() {} + +var File_plugin_proto protoreflect.FileDescriptor + +const file_plugin_proto_rawDesc = "" + + "\n" + + "\fplugin.proto\x12\x11sub2api.plugin.v1\"\x10\n" + + "\x0eGetInfoRequest\"\xd8\x01\n" + + "\x0fGetInfoResponse\x12\x1b\n" + + "\tplugin_id\x18\x01 \x01(\tR\bpluginId\x12%\n" + + "\x0eplugin_version\x18\x02 \x01(\tR\rpluginVersion\x12)\n" + + "\x10protocol_version\x18\x03 \x01(\rR\x0fprotocolVersion\x122\n" + + "\x15transport_api_version\x18\x04 \x01(\rR\x13transportApiVersion\x12\"\n" + + "\fcapabilities\x18\x05 \x03(\tR\fcapabilities\"\x0f\n" + + "\rHealthRequest\"D\n" + + "\x0eHealthResponse\x12\x18\n" + + "\ahealthy\x18\x01 \x01(\bR\ahealthy\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"8\n" + + "\x15ValidateConfigRequest\x12\x1f\n" + + "\vconfig_json\x18\x01 \x01(\fR\n" + + "configJson\"~\n" + + "\x16ValidateConfigResponse\x12\x14\n" + + "\x05valid\x18\x01 \x01(\bR\x05valid\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x124\n" + + "\x16normalized_config_json\x18\x03 \x01(\fR\x14normalizedConfigJson\"5\n" + + "\x12ApplyConfigRequest\x12\x1f\n" + + "\vconfig_json\x18\x01 \x01(\fR\n" + + "configJson\"I\n" + + "\x13ApplyConfigResponse\x12\x18\n" + + "\aapplied\x18\x01 \x01(\bR\aapplied\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"4\n" + + "\x11TestConfigRequest\x12\x1f\n" + + "\vconfig_json\x18\x01 \x01(\fR\n" + + "configJson\"g\n" + + "\x12TestConfigResponse\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1d\n" + + "\n" + + "latency_ms\x18\x03 \x01(\x03R\tlatencyMs\"&\n" + + "\fHeaderValues\x12\x16\n" + + "\x06values\x18\x01 \x03(\tR\x06values\"\x8c\x04\n" + + "\x13ForwardRequestStart\x12\x1d\n" + + "\n" + + "request_id\x18\x01 \x01(\tR\trequestId\x12\x16\n" + + "\x06method\x18\x02 \x01(\tR\x06method\x12\x10\n" + + "\x03url\x18\x03 \x01(\tR\x03url\x12\x12\n" + + "\x04host\x18\x04 \x01(\tR\x04host\x12M\n" + + "\aheaders\x18\x05 \x03(\v23.sub2api.plugin.v1.ForwardRequestStart.HeadersEntryR\aheaders\x12\x1b\n" + + "\tproxy_url\x18\x06 \x01(\tR\bproxyUrl\x12\x1d\n" + + "\n" + + "account_id\x18\a \x01(\x03R\taccountId\x12/\n" + + "\x13account_concurrency\x18\b \x01(\x05R\x12accountConcurrency\x12\x1a\n" + + "\bplatform\x18\t \x01(\tR\bplatform\x12!\n" + + "\faccount_type\x18\n" + + " \x01(\tR\vaccountType\x12%\n" + + "\x0econtent_length\x18\v \x01(\x03R\rcontentLength\x12\x19\n" + + "\bhas_body\x18\f \x01(\bR\ahasBody\x1a[\n" + + "\fHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x125\n" + + "\x05value\x18\x02 \x01(\v2\x1f.sub2api.plugin.v1.HeaderValuesR\x05value:\x028\x01\"\x97\x01\n" + + "\x0eForwardRequest\x12>\n" + + "\x05start\x18\x01 \x01(\v2&.sub2api.plugin.v1.ForwardRequestStartH\x00R\x05start\x12\x1f\n" + + "\n" + + "body_chunk\x18\x02 \x01(\fH\x00R\tbodyChunk\x12\x1b\n" + + "\bbody_end\x18\x03 \x01(\bH\x00R\abodyEndB\a\n" + + "\x05frame\"\x8d\x03\n" + + "\x14ForwardResponseStart\x12\x1f\n" + + "\vstatus_code\x18\x01 \x01(\x05R\n" + + "statusCode\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\x12\x1a\n" + + "\bprotocol\x18\x03 \x01(\tR\bprotocol\x12%\n" + + "\x0eprotocol_major\x18\x04 \x01(\x05R\rprotocolMajor\x12%\n" + + "\x0eprotocol_minor\x18\x05 \x01(\x05R\rprotocolMinor\x12N\n" + + "\aheaders\x18\x06 \x03(\v24.sub2api.plugin.v1.ForwardResponseStart.HeadersEntryR\aheaders\x12%\n" + + "\x0econtent_length\x18\a \x01(\x03R\rcontentLength\x1a[\n" + + "\fHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x125\n" + + "\x05value\x18\x02 \x01(\v2\x1f.sub2api.plugin.v1.HeaderValuesR\x05value:\x028\x01\"\\\n" + + "\x12ForwardResponseEnd\x12%\n" + + "\x0ebytes_received\x18\x01 \x01(\x03R\rbytesReceived\x12\x1f\n" + + "\vduration_ms\x18\x02 \x01(\x03R\n" + + "durationMs\"g\n" + + "\x14ForwardResponseError\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12!\n" + + "\frequest_sent\x18\x03 \x01(\bR\vrequestSent\"\xf8\x01\n" + + "\x0fForwardResponse\x12?\n" + + "\x05start\x18\x01 \x01(\v2'.sub2api.plugin.v1.ForwardResponseStartH\x00R\x05start\x12\x1f\n" + + "\n" + + "body_chunk\x18\x02 \x01(\fH\x00R\tbodyChunk\x129\n" + + "\x03end\x18\x03 \x01(\v2%.sub2api.plugin.v1.ForwardResponseEndH\x00R\x03end\x12?\n" + + "\x05error\x18\x04 \x01(\v2'.sub2api.plugin.v1.ForwardResponseErrorH\x00R\x05errorB\a\n" + + "\x05frame2\xa8\x04\n" + + "\x0fTransportPlugin\x12P\n" + + "\aGetInfo\x12!.sub2api.plugin.v1.GetInfoRequest\x1a\".sub2api.plugin.v1.GetInfoResponse\x12M\n" + + "\x06Health\x12 .sub2api.plugin.v1.HealthRequest\x1a!.sub2api.plugin.v1.HealthResponse\x12e\n" + + "\x0eValidateConfig\x12(.sub2api.plugin.v1.ValidateConfigRequest\x1a).sub2api.plugin.v1.ValidateConfigResponse\x12\\\n" + + "\vApplyConfig\x12%.sub2api.plugin.v1.ApplyConfigRequest\x1a&.sub2api.plugin.v1.ApplyConfigResponse\x12Y\n" + + "\n" + + "TestConfig\x12$.sub2api.plugin.v1.TestConfigRequest\x1a%.sub2api.plugin.v1.TestConfigResponse\x12T\n" + + "\aForward\x12!.sub2api.plugin.v1.ForwardRequest\x1a\".sub2api.plugin.v1.ForwardResponse(\x010\x01B7Z5github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1;pluginv1b\x06proto3" + +var ( + file_plugin_proto_rawDescOnce sync.Once + file_plugin_proto_rawDescData []byte +) + +func file_plugin_proto_rawDescGZIP() []byte { + file_plugin_proto_rawDescOnce.Do(func() { + file_plugin_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_plugin_proto_rawDesc), len(file_plugin_proto_rawDesc))) + }) + return file_plugin_proto_rawDescData +} + +var file_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 19) +var file_plugin_proto_goTypes = []any{ + (*GetInfoRequest)(nil), // 0: sub2api.plugin.v1.GetInfoRequest + (*GetInfoResponse)(nil), // 1: sub2api.plugin.v1.GetInfoResponse + (*HealthRequest)(nil), // 2: sub2api.plugin.v1.HealthRequest + (*HealthResponse)(nil), // 3: sub2api.plugin.v1.HealthResponse + (*ValidateConfigRequest)(nil), // 4: sub2api.plugin.v1.ValidateConfigRequest + (*ValidateConfigResponse)(nil), // 5: sub2api.plugin.v1.ValidateConfigResponse + (*ApplyConfigRequest)(nil), // 6: sub2api.plugin.v1.ApplyConfigRequest + (*ApplyConfigResponse)(nil), // 7: sub2api.plugin.v1.ApplyConfigResponse + (*TestConfigRequest)(nil), // 8: sub2api.plugin.v1.TestConfigRequest + (*TestConfigResponse)(nil), // 9: sub2api.plugin.v1.TestConfigResponse + (*HeaderValues)(nil), // 10: sub2api.plugin.v1.HeaderValues + (*ForwardRequestStart)(nil), // 11: sub2api.plugin.v1.ForwardRequestStart + (*ForwardRequest)(nil), // 12: sub2api.plugin.v1.ForwardRequest + (*ForwardResponseStart)(nil), // 13: sub2api.plugin.v1.ForwardResponseStart + (*ForwardResponseEnd)(nil), // 14: sub2api.plugin.v1.ForwardResponseEnd + (*ForwardResponseError)(nil), // 15: sub2api.plugin.v1.ForwardResponseError + (*ForwardResponse)(nil), // 16: sub2api.plugin.v1.ForwardResponse + nil, // 17: sub2api.plugin.v1.ForwardRequestStart.HeadersEntry + nil, // 18: sub2api.plugin.v1.ForwardResponseStart.HeadersEntry +} +var file_plugin_proto_depIdxs = []int32{ + 17, // 0: sub2api.plugin.v1.ForwardRequestStart.headers:type_name -> sub2api.plugin.v1.ForwardRequestStart.HeadersEntry + 11, // 1: sub2api.plugin.v1.ForwardRequest.start:type_name -> sub2api.plugin.v1.ForwardRequestStart + 18, // 2: sub2api.plugin.v1.ForwardResponseStart.headers:type_name -> sub2api.plugin.v1.ForwardResponseStart.HeadersEntry + 13, // 3: sub2api.plugin.v1.ForwardResponse.start:type_name -> sub2api.plugin.v1.ForwardResponseStart + 14, // 4: sub2api.plugin.v1.ForwardResponse.end:type_name -> sub2api.plugin.v1.ForwardResponseEnd + 15, // 5: sub2api.plugin.v1.ForwardResponse.error:type_name -> sub2api.plugin.v1.ForwardResponseError + 10, // 6: sub2api.plugin.v1.ForwardRequestStart.HeadersEntry.value:type_name -> sub2api.plugin.v1.HeaderValues + 10, // 7: sub2api.plugin.v1.ForwardResponseStart.HeadersEntry.value:type_name -> sub2api.plugin.v1.HeaderValues + 0, // 8: sub2api.plugin.v1.TransportPlugin.GetInfo:input_type -> sub2api.plugin.v1.GetInfoRequest + 2, // 9: sub2api.plugin.v1.TransportPlugin.Health:input_type -> sub2api.plugin.v1.HealthRequest + 4, // 10: sub2api.plugin.v1.TransportPlugin.ValidateConfig:input_type -> sub2api.plugin.v1.ValidateConfigRequest + 6, // 11: sub2api.plugin.v1.TransportPlugin.ApplyConfig:input_type -> sub2api.plugin.v1.ApplyConfigRequest + 8, // 12: sub2api.plugin.v1.TransportPlugin.TestConfig:input_type -> sub2api.plugin.v1.TestConfigRequest + 12, // 13: sub2api.plugin.v1.TransportPlugin.Forward:input_type -> sub2api.plugin.v1.ForwardRequest + 1, // 14: sub2api.plugin.v1.TransportPlugin.GetInfo:output_type -> sub2api.plugin.v1.GetInfoResponse + 3, // 15: sub2api.plugin.v1.TransportPlugin.Health:output_type -> sub2api.plugin.v1.HealthResponse + 5, // 16: sub2api.plugin.v1.TransportPlugin.ValidateConfig:output_type -> sub2api.plugin.v1.ValidateConfigResponse + 7, // 17: sub2api.plugin.v1.TransportPlugin.ApplyConfig:output_type -> sub2api.plugin.v1.ApplyConfigResponse + 9, // 18: sub2api.plugin.v1.TransportPlugin.TestConfig:output_type -> sub2api.plugin.v1.TestConfigResponse + 16, // 19: sub2api.plugin.v1.TransportPlugin.Forward:output_type -> sub2api.plugin.v1.ForwardResponse + 14, // [14:20] is the sub-list for method output_type + 8, // [8:14] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name +} + +func init() { file_plugin_proto_init() } +func file_plugin_proto_init() { + if File_plugin_proto != nil { + return + } + file_plugin_proto_msgTypes[12].OneofWrappers = []any{ + (*ForwardRequest_Start)(nil), + (*ForwardRequest_BodyChunk)(nil), + (*ForwardRequest_BodyEnd)(nil), + } + file_plugin_proto_msgTypes[16].OneofWrappers = []any{ + (*ForwardResponse_Start)(nil), + (*ForwardResponse_BodyChunk)(nil), + (*ForwardResponse_End)(nil), + (*ForwardResponse_Error)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_plugin_proto_rawDesc), len(file_plugin_proto_rawDesc)), + NumEnums: 0, + NumMessages: 19, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_plugin_proto_goTypes, + DependencyIndexes: file_plugin_proto_depIdxs, + MessageInfos: file_plugin_proto_msgTypes, + }.Build() + File_plugin_proto = out.File + file_plugin_proto_goTypes = nil + file_plugin_proto_depIdxs = nil +} diff --git a/backend/pkg/pluginapi/v1/plugin.proto b/backend/pkg/pluginapi/v1/plugin.proto new file mode 100644 index 000000000..e4b8fdac3 --- /dev/null +++ b/backend/pkg/pluginapi/v1/plugin.proto @@ -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 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 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; + } +} diff --git a/backend/pkg/pluginapi/v1/plugin_grpc.pb.go b/backend/pkg/pluginapi/v1/plugin_grpc.pb.go new file mode 100644 index 000000000..ee07e1b1d --- /dev/null +++ b/backend/pkg/pluginapi/v1/plugin_grpc.pb.go @@ -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", +} diff --git a/backend/pkg/pluginapi/v1/runtime.go b/backend/pkg/pluginapi/v1/runtime.go new file mode 100644 index 000000000..662519c37 --- /dev/null +++ b/backend/pkg/pluginapi/v1/runtime.go @@ -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, + }) +} diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 38a50d060..1e4de333e 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -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) # 定价数据源(可选) diff --git a/docs/PLUGIN_DEVELOPMENT.md b/docs/PLUGIN_DEVELOPMENT.md new file mode 100644 index 000000000..1b7b24894 --- /dev/null +++ b/docs/PLUGIN_DEVELOPMENT.md @@ -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//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//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 版本、示例插件版本和构建说明。 diff --git a/docs/screenshots/mobile-account-actions-menu.png b/docs/screenshots/mobile-account-actions-menu.png deleted file mode 100644 index f09684a61..000000000 Binary files a/docs/screenshots/mobile-account-actions-menu.png and /dev/null differ diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index dd976daad..0616449f1 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -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' diff --git a/frontend/src/api/admin/plugins.ts b/frontend/src/api/admin/plugins.ts new file mode 100644 index 000000000..8b4489d41 --- /dev/null +++ b/frontend/src/api/admin/plugins.ts @@ -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 { + const { data } = await apiClient.get('/admin/plugins') + return data +} + +export async function upload(file: File): Promise { + const form = new FormData() + form.append('plugin', file) + const { data } = await apiClient.post('/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 { + const { data } = await apiClient.post(`/admin/plugins/${id}/enable`, { + rollout_percent: rolloutPercent, + accept_untested: acceptUntested + }) + return data +} + +export async function disable(id: number): Promise { + const { data } = await apiClient.post(`/admin/plugins/${id}/disable`) + return data +} + +export async function remove(id: number): Promise { + await apiClient.delete(`/admin/plugins/${id}`) +} + +export async function getConfig(id: number): Promise> { + const { data } = await apiClient.get>(`/admin/plugins/${id}/config`) + return data +} + +export async function saveConfig( + id: number, + config: Record +): Promise> { + const { data } = await apiClient.put>(`/admin/plugins/${id}/config`, config) + return data +} + +export async function test(id: number): Promise { + const { data } = await apiClient.post(`/admin/plugins/${id}/test`) + return data +} + +export async function createUISession(id: number): Promise { + const { data } = await apiClient.post(`/admin/plugins/${id}/ui-session`) + return data +} + +export default { + list, + upload, + enable, + disable, + remove, + getConfig, + saveConfig, + test, + createUISession +} diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index f5f19918a..4ceb75cbd 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -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; diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index d3644638b..f39c8ed1b 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -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 }, { diff --git a/frontend/src/i18n/locales/en/admin/index.ts b/frontend/src/i18n/locales/en/admin/index.ts index 224ab94b3..b86114e05 100644 --- a/frontend/src/i18n/locales/en/admin/index.ts +++ b/frontend/src/i18n/locales/en/admin/index.ts @@ -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, } diff --git a/frontend/src/i18n/locales/en/admin/plugins.ts b/frontend/src/i18n/locales/en/admin/plugins.ts new file mode 100644 index 000000000..1d68ba6dc --- /dev/null +++ b/frontend/src/i18n/locales/en/admin/plugins.ts @@ -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.' + } +} diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 2d6d34409..4c23f239a 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -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.', diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index a0190ca0b..d0afbee55 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -176,6 +176,7 @@ export default { modelPlaza: 'Model Plaza', subscriptions: 'Subscriptions', accounts: 'Accounts', + plugins: 'Plugins', proxies: 'Proxies', redeemCodes: 'Redeem Codes', ops: 'Ops', diff --git a/frontend/src/i18n/locales/zh/admin/index.ts b/frontend/src/i18n/locales/zh/admin/index.ts index 224ab94b3..b86114e05 100644 --- a/frontend/src/i18n/locales/zh/admin/index.ts +++ b/frontend/src/i18n/locales/zh/admin/index.ts @@ -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, } diff --git a/frontend/src/i18n/locales/zh/admin/plugins.ts b/frontend/src/i18n/locales/zh/admin/plugins.ts new file mode 100644 index 000000000..8b279bd8f --- /dev/null +++ b/frontend/src/i18n/locales/zh/admin/plugins.ts @@ -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: '作用域为平台与账号类型,不修改账号数据,也不需要在账号页逐个开启。' + } +} diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index f31548066..fb5371e8c 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -52,6 +52,12 @@ export default { priceDescription: '价格说明(Markdown)', priceDescriptionHint: '展示在模型广场页面顶部,可用于说明计费规则、汇率、优惠活动等。', }, + pluginManagement: { + title: '插件管理', + description: '控制管理员侧边栏是否显示插件管理入口。此开关不控制插件运行状态。', + enabled: '显示插件管理菜单', + enabledHint: '关闭后仅隐藏侧边栏菜单;已加载或正在运行的插件不会因此停止。', + }, riskControl: { title: '风控中心', description: '启用内容审计菜单和全端点请求审核入口。默认关闭。', diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts index 04dd32128..c5b2c4002 100644 --- a/frontend/src/i18n/locales/zh/common.ts +++ b/frontend/src/i18n/locales/zh/common.ts @@ -176,6 +176,7 @@ export default { modelPlaza: '模型广场', subscriptions: '订阅管理', accounts: '账号管理', + plugins: '插件管理', proxies: 'IP管理', redeemCodes: '兑换码', ops: '运维监控', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 1221a53f6..1191944f5 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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', diff --git a/frontend/src/stores/__tests__/app.spec.ts b/frontend/src/stores/__tests__/app.spec.ts index d5cee3606..d2c55d13e 100644 --- a/frontend/src/stores/__tests__/app.spec.ts +++ b/frontend/src/stores/__tests__/app.spec.ts @@ -57,6 +57,7 @@ function createPublicSettings(overrides: Partial = {}): 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, diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index e2d614ec3..496e17d27 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -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, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index eaa585f15..df6075c5c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 diff --git a/frontend/src/utils/featureFlags.ts b/frontend/src/utils/featureFlags.ts index f2e36b4a6..5cf6c46b3 100644 --- a/frontend/src/utils/featureFlags.ts +++ b/frontend/src/utils/featureFlags.ts @@ -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', diff --git a/frontend/src/views/admin/PluginsView.vue b/frontend/src/views/admin/PluginsView.vue new file mode 100644 index 000000000..fbfcfa4b7 --- /dev/null +++ b/frontend/src/views/admin/PluginsView.vue @@ -0,0 +1,671 @@ +