From 40ea3aebad7d9ff786171a40b14c9920bc006a96 Mon Sep 17 00:00:00 2001 From: shaw Date: Mon, 24 Aug 2026 09:03:37 +0800 Subject: [PATCH] feat: add OAuth outbound transport plugin system --- .dockerignore | 3 + .gitignore | 12 +- backend/cmd/server/main.go | 5 + backend/cmd/server/wire.go | 36 +- backend/cmd/server/wire_gen.go | 40 +- backend/cmd/server/wire_gen_test.go | 1 + backend/go.mod | 17 +- backend/go.sum | 71 +- backend/internal/config/config.go | 31 +- .../internal/handler/admin/plugin_handler.go | 257 ++++ .../internal/handler/admin/setting_handler.go | 7 +- .../handler/admin/setting_handler_update.go | 16 +- backend/internal/handler/dto/settings.go | 12 +- backend/internal/handler/handler.go | 1 + backend/internal/handler/setting_handler.go | 5 +- backend/internal/handler/wire.go | 5 + backend/internal/repository/plugin_repo.go | 335 +++++ .../plugin_repo_integration_test.go | 70 + backend/internal/repository/wire.go | 1 + .../server/middleware/security_headers.go | 65 +- .../middleware/security_headers_test.go | 43 + backend/internal/server/routes/admin.go | 22 + .../internal/service/account_test_service.go | 13 +- backend/internal/service/domain_constants.go | 4 + .../internal/service/openai_alpha_search.go | 4 +- .../service/openai_codex_models_service.go | 22 +- backend/internal/service/openai_embeddings.go | 2 +- .../service/openai_gateway_cc_pipeline.go | 2 +- .../openai_gateway_chat_completions.go | 2 +- ...teway_chat_completions_anthropic_native.go | 2 +- .../service/openai_gateway_count_tokens.go | 4 +- .../service/openai_gateway_forward.go | 2 +- .../internal/service/openai_gateway_grok.go | 4 +- .../openai_gateway_grok_chat_bridge.go | 2 +- .../service/openai_gateway_messages.go | 2 +- ...penai_gateway_messages_anthropic_native.go | 2 +- .../service/openai_gateway_passthrough.go | 2 +- ...enai_gateway_responses_anthropic_native.go | 2 +- .../service/openai_gateway_service.go | 1 + backend/internal/service/openai_images.go | 2 +- .../service/openai_images_responses.go | 2 +- backend/internal/service/openai_live.go | 2 +- .../service/openai_plugin_transport.go | 45 + .../openai_upstream_transport_error.go | 8 +- .../service/openai_ws_forwarder_ingress.go | 3 +- .../internal/service/openai_ws_http_bridge.go | 2 +- .../internal/service/plugin_compatibility.go | 104 ++ .../service/plugin_compatibility_test.go | 48 + backend/internal/service/plugin_manager.go | 1048 +++++++++++++ .../service/plugin_manager_routing_test.go | 114 ++ backend/internal/service/plugin_manifest.go | 214 +++ backend/internal/service/plugin_package.go | 387 +++++ .../internal/service/plugin_package_test.go | 245 +++ backend/internal/service/plugin_runtime.go | 394 +++++ .../plugin_runtime_integration_test.go | 105 ++ .../plugin_security_regression_test.go | 239 +++ backend/internal/service/setting_parse.go | 8 +- backend/internal/service/setting_public.go | 8 +- backend/internal/service/setting_update.go | 1 + backend/internal/service/settings_view.go | 12 +- backend/internal/service/wire.go | 3 + backend/migrations/229_plugins.sql | 54 + backend/migrations/230_plugin_artifacts.sql | 4 + backend/migrations/plugins_migration_test.go | 35 + backend/pkg/pluginapi/README.md | 80 + backend/pkg/pluginapi/docs/development.md | 67 + backend/pkg/pluginapi/docs/package-format.md | 45 + backend/pkg/pluginapi/docs/security.md | 29 + backend/pkg/pluginapi/docs/ui-bridge.md | 56 + backend/pkg/pluginapi/v1/manifest.schema.json | 75 + .../pkg/pluginapi/v1/manifest_schema_test.go | 21 + backend/pkg/pluginapi/v1/plugin.pb.go | 1315 +++++++++++++++++ backend/pkg/pluginapi/v1/plugin.proto | 117 ++ backend/pkg/pluginapi/v1/plugin_grpc.pb.go | 306 ++++ backend/pkg/pluginapi/v1/runtime.go | 59 + deploy/config.example.yaml | 22 +- docs/PLUGIN_DEVELOPMENT.md | 233 +++ .../mobile-account-actions-menu.png | Bin 289192 -> 0 bytes frontend/src/api/admin/index.ts | 13 +- frontend/src/api/admin/plugins.ts | 156 ++ frontend/src/api/admin/settings.ts | 2 + frontend/src/components/layout/AppSidebar.vue | 7 + frontend/src/i18n/locales/en/admin/index.ts | 2 + frontend/src/i18n/locales/en/admin/plugins.ts | 50 + .../src/i18n/locales/en/admin/settings.ts | 6 + frontend/src/i18n/locales/en/common.ts | 1 + frontend/src/i18n/locales/zh/admin/index.ts | 2 + frontend/src/i18n/locales/zh/admin/plugins.ts | 50 + .../src/i18n/locales/zh/admin/settings.ts | 6 + frontend/src/i18n/locales/zh/common.ts | 1 + frontend/src/router/index.ts | 12 + frontend/src/stores/__tests__/app.spec.ts | 1 + frontend/src/stores/app.ts | 1 + frontend/src/types/index.ts | 1 + frontend/src/utils/featureFlags.ts | 5 + frontend/src/views/admin/PluginsView.vue | 671 +++++++++ frontend/src/views/admin/SettingsView.vue | 27 + .../views/admin/__tests__/PluginsView.spec.ts | 169 +++ 98 files changed, 7681 insertions(+), 141 deletions(-) create mode 100644 backend/internal/handler/admin/plugin_handler.go create mode 100644 backend/internal/repository/plugin_repo.go create mode 100644 backend/internal/repository/plugin_repo_integration_test.go create mode 100644 backend/internal/service/openai_plugin_transport.go create mode 100644 backend/internal/service/plugin_compatibility.go create mode 100644 backend/internal/service/plugin_compatibility_test.go create mode 100644 backend/internal/service/plugin_manager.go create mode 100644 backend/internal/service/plugin_manager_routing_test.go create mode 100644 backend/internal/service/plugin_manifest.go create mode 100644 backend/internal/service/plugin_package.go create mode 100644 backend/internal/service/plugin_package_test.go create mode 100644 backend/internal/service/plugin_runtime.go create mode 100644 backend/internal/service/plugin_runtime_integration_test.go create mode 100644 backend/internal/service/plugin_security_regression_test.go create mode 100644 backend/migrations/229_plugins.sql create mode 100644 backend/migrations/230_plugin_artifacts.sql create mode 100644 backend/migrations/plugins_migration_test.go create mode 100644 backend/pkg/pluginapi/README.md create mode 100644 backend/pkg/pluginapi/docs/development.md create mode 100644 backend/pkg/pluginapi/docs/package-format.md create mode 100644 backend/pkg/pluginapi/docs/security.md create mode 100644 backend/pkg/pluginapi/docs/ui-bridge.md create mode 100644 backend/pkg/pluginapi/v1/manifest.schema.json create mode 100644 backend/pkg/pluginapi/v1/manifest_schema_test.go create mode 100644 backend/pkg/pluginapi/v1/plugin.pb.go create mode 100644 backend/pkg/pluginapi/v1/plugin.proto create mode 100644 backend/pkg/pluginapi/v1/plugin_grpc.pb.go create mode 100644 backend/pkg/pluginapi/v1/runtime.go create mode 100644 docs/PLUGIN_DEVELOPMENT.md delete mode 100644 docs/screenshots/mobile-account-actions-menu.png create mode 100644 frontend/src/api/admin/plugins.ts create mode 100644 frontend/src/i18n/locales/en/admin/plugins.ts create mode 100644 frontend/src/i18n/locales/zh/admin/plugins.ts create mode 100644 frontend/src/views/admin/PluginsView.vue create mode 100644 frontend/src/views/admin/__tests__/PluginsView.spec.ts 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 f09684a6127cc4d9a54d73f417034f7ff8ccb9a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 289192 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

Z-oq*o(G#TOXr3in?7M(r@ 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
P4kigs^>^zd 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

    c2UBFAnur1B%0zDJ;f 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≶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
      !+Ku?d)!%;W~Iff~keGdzP73Ho*Ie&k) z18wr3&XAmdJMl25ibA69z`g&?x$V&3M&IIYW%6%T;}&X;Mry`n8qson#_f5FK`iEf z46Lq~%00XJc1Sh9z`swR{`lyF6E_V&pH^$Vgx*qht7AN=Da{5lc4+vlmjWfd)dytW zEGy;KetEwO;2SpJLOPmuyg9T3P> z1*>q%(|r5j)E%?BG0>bbH zV~= zGNkzzr+qRDIDJ%Ac?mbtcNonjIt*edmtDT_KhnW~8B6j$@P9aZ6;ajo|Jb#~|KDm# zdP#YbK0lx*k9j+G4Hx7YE{+tCuCpiYH{-K`+wlKLZ!bH-9-x@xXTra+%)^_Eq$wW> zh@RvWXGcF==3ihL+7WWbAejFZ+U{iy;(6I%29n}945){8t+9-_DQ3vPFQF8{EhHj* zopoqEKfSL7%x0tIv&f1Hi|NQ~8*!g6OIy4!^|}f!l9BNkKMc7%`ugL=X01$cR<+pN z&!Ybd3SSdQzy$^!6!h=%{HK<1DZ+Q>lg+D>B~GJTm0+jbC2eqXs=lS!pk&kv$S}YA zO|cHfU0+=XPD69RKeMLFcgPU(=stWzEMgHVS@rIK3&fUOo7$ zSUlp*XWMRYFQ}t2zZxs+{|ye3CgaF}_56;(wzq4J|QVd3GsJEpzB;WYs` zDO{TL)yC^^@6m|AdW5YqT#3DvM)mc;*Sl`7^OU0EC(4}HRI5WK@!7+-S+$)QsHuY| z4}Ndvjd`@WtEi|D^I3d|3&8EpVZ6q7Ac1iAZidhHvLAjz-LYn+*s3dms-IpGf;Fbj z^4=bLHU_n}#%G0LlBTRXeQ`~~%E}%2z{CTxQf)c>`{4G=tJfH$*T?a|5jb*r_WMup zdKtw@qOp#&jqR=@@zlEkiz2(8n)uF%j_buh1UmDS@&gx4yh&0`YSj;JKsLhbc2_vg zU7Y>d+~mI8FIDd=>@>k=k9jiEYBMVGfX}QYU-Ceq{m!ItRF=)O5m3bInmVVX1mECu zcmXu0K5TxKF1p*_8Y_V|#Cg%*`sejbQxkGIbq(*r`idiX*i-wDRTbiZ0icqyGVPEp#l!+(j4XwedG%js_FEu}sJxW&eNuLq+>f(I3qn>Kl_wIKAaP8j&EI4bzU9HH^-Mto(d-idEco&-SpSu@zd`-gOcf#k0~8p;dI_t|F$HU7#l>X@HRz~|?&jO$~M$=AArC3%F37=!A_ggU+mrKB8zxgq5tnhAjpyTwPXkhZ(sxX9M5p(OjLKWaEEjAD@ ziu{D4`o;T(*ce`h7na=lsVw@@#a|Le7^fp{tuVUMn4DHpRgI8MI2b5ZtZEu!2HjsJ zMm-bvW$Da4p(segl1 zjaT&KaIjhDJ><^p&X4?-(0yOi-F>lN&E9FerfXJUxTkP?(-4m+n9Gg!d`s`Fdcb2=Ml~ zKN-l2ti|$>O`9oYurC>@a%U!QWk9?i_*k$LF2|v^IYmJ)QXjP6l~-dCQF% zPdZ*yN@|n`v%qT`Bcmjl&s?bYwUL;j=byeATkjJvUtQ*Qrk(hfboiJ zjQKtU0$jfR$mZ_y@)@1qlHu*=@4e3sTOP9sg^9akvE<22Ws^rjLOMbZA95=1bA@An zfTJacGOdYf|NJO{xOz(_)+Tj9k`1|ujW6bxc7bC_sg5}Ct0D!fPfY!45H$vI*~D+X z=f8cHL;8-?72vC7S^|#(0dK8IWNIT{?T2MrS_HSObwR3q>_C-Id6-nob0BQq!-u>V z{b>2nvY+pL$!O`7dDoSG*+lsVeHW(Q2xpX*_M6Li$v2Tg4V9PnK@0+O09ljCCoAl@ z_3Dc2H?SFf-DbMNdqA8)PaZzyyk(y}va!ptLIdl~eEyLW8lY*XwDI0!!V~0`rQvAq zCHog}Qqyb;YsG|J#ycQls#uS6df?)kX5w;Z;RClAlT;}hyub3GW9I!Js2zY96^9g z<$k`x4X90R-ZJ&SQ8s(_tITFHSC_~m)dE@!JBve%{3}X<;o=~r19-0CaeSZn`cBW8 zGyX2~-1)iBE&p{K9G4F$aeU7m^fCvLq@@0FwQI58LHp^>^L322`Y%hAom8sRrT;WW*u}7!&Jn!< zeZXA5tlO@yM!)apPaNFFd3D(@e3GSE(&8~p0vG`sJmpv-8`EE7*sKJ+%U_R6cVil2 zQeBp0hl(u~R#WQeTmb?ZCusf60hm1P0q?;lCXt11NP1nPH!+Yb_p)f@kUe$U9S#oQ zGjg^fkWUuNd)i+S6{yw`*{0ifknpch1E8O)p+`{b&mu9P12}m5IeoCa)pluR?ttR2 z^M)j;nf|CSx@~<-MB%bI(J?>cGeEYx#9x1*$l7d}M&Br#zjORqD4PQ$VKn!-xf4m` zCD4Z3br%;BatD&&GWbm5DvLPLCKQxQEE2AI_H5#>ZO&mI#zGCs&6L4Pb#PrBuiq*mOI~DaVQ}|HwbK>abZD^EhAC$}K6bgGAq{}V z214DS5uf_Q-e!~AKPI(gRW>%Fr$W6TpR!(cfYqYSGK;uJ2QQY9?R<1C`JxTkO3~Nb z4S!Qb-nG(e7c{WHgns||q5c|Q^XZ9SviolWQXhqjRaKSS6H}~Byp^fd9#?unOm?u< z^947xu@o=nJU}tca-1sjUOr1XoWVMAklWYf7B~0ZTPZY* zfT8ey@C4S-uCyUzT8<>Fgj_zI95NS^r$@$@N)wl*?>n{2tN;@n+;AQ2ZS}(WDqxoX z@!|@H226*%`j-3i>o2G?6ZB?G>E$<)2J;NY-SBS3&c9z^fM1&B)7Z)T%Y%p2#rgrT z>UZGKh)Ke40n53$S|9h?MN-EpBZ$IVZo|i$UP@CwGV^7#SYb2M(eDWz`5#ihe+M*2 zBYO^xyMnM$J&>mtgx9)P=bSe_uj_yMF`y&Zxf@fJ8QMoCE>NE$Y=7aQ1BPhtmJpc_rXluHlmJZ2L1-Z8&4 z?pQXs|1x^ORb({~6%ZgV;F#v$C>wuGF`moX zP77cWORDx)W+f@65)@xl78%#w75&UOr#7_Nw-hP-aU=B{-Ridg|SB zf$%7*?W!y&lr)hsUCXo+VW-YrDg&N-ryo$9!t?_jS_`qW=gsl5A{@)ZMb)W=8ur4l z-ikBS;0)f&`wBwNTv@kfs#4!oofV(bib_b3crz@cS#6=fbN6ngs#Ql_Z53g8f?HHH zr@gx3?gEME2pobax?GlI$q)8aZm#DpiV1BMo|T^96Mc{as%uW&f{r%P8s|;a<=a)# zSe||>A&C>Kg%cn5KE9aljV5*FqT+KNxRvD60j!&y5zWo&Lb^%*sk|v`eCn&y z14-{GZnKerU0g@^5Ys`vkf)3j03`-MY@YUEw-L_uZu|qq&iClp7n<%!86IA}Iy`@= z^{T!N!3y~C72Q|L@Kqh&*!WHFCh;W-*bMZQZZFWj$G5n%@#8CiVdMV%@Be&pkUv0y zWFrgYfA6Y5`TXB%Z#~xVA2`_2!ZHv?Z1Uf3&&>_{#pRot&Mhgo1FyG^qzWpN?iFJL zltWCd^&z6{#`d!GFL36=E_o%|LnJP}44t&q=OL|1Hz4Hw^Ru{PokD*yaCqx24$}gb zCF}u%+mAtsrs49gy`C5V(X4AR}w($d{A(%sF#@SVZu`QG0bf5O1rbMHN| z_g-tSWlQBd2F?|<8Va&mm9*`G2Qmi|85&VIGepa4&%vcir72WzBBK6Et%1I|3mnvF zj7w7Xu1k02?ayAL>CdZ(Nw++f|Ac$mPPGGNweJrJ#daTwlk#{LRU|AdQf69d-i#o_xm=;r;A$Mb`1-x!F(; zJwC|AMcUXRmvOl^At9k_{$zZNxlCxsuJPEm!2URRL&f*(VholGd4Q^$zCbmLPL&xl zbVlfcZL3qJkKJ9;y%14^qobydoAk>}S~<98 zAc!>9KVlJ>%F4>~q?t+Yi)qtnyskFD1B1H)MKWz&U8>Faz|8c~4DHf?VhnDC|D`N&bo$QBOdl^CA30j+j&S=g@jl}0@G*CM zuws?SmQZssU&>nbS9V$FCWhk98P56-d#u&GbB4%Nifg`2zP-o{NZugZWlKZVuG=4g z$a&q~Hq1o#jnnbPaO5J??y9DHIkq7(Bn{}4ZgSw+M{1mcj}#gbJb`4%u=cxBps#2v zxdXJ>GUT2F7sb7{Ms06;wI%sB!Exg&I$Pd*qm;rL1F`lh5za@Jv|+TsQZU75_lI{j z#~Uz@@{Zx0Z`IrGY<40X`gbPx6{M^FnXSLjDc()=v}{4Vyv)5q;qHk zt+WS_P&*({d#(1xF_s36`LbP~?Nx(zQ+)HHEk(ZFQ$~2)Opn1kIze?kOV1z@P*gT9 z6)J3|=%;L_%0S|7Y%0I@9lJNdZJv9A`fUkXFXq6;N8?jC$(dy*<%;e;%s}D1s;o@a z9|lZV^wr~K-KEXefxJz*LFT0v_C`l!WMqMMslK(j$s2hb%hulYB26Zc7eSHbnX|A3 z(rf7rnuSA|Mo*dF9XYmF`Q|IW_Q6(R1lwEZ!V+4bz9D!Nrqz_B#k-TIqFr9*_3_2q z5*_!MJ1y4;x1Op*wV3vn0ws#WgV?6~s%1pubA64yf4)+_<|vo?~pk!5@r zk93?%u%3mh5$r4B!r1F}S{(M0N;eLeZ+ul&)P+lGL`@dl6g}k!CS-CsIQ+_#X-8yz zlV&fT^me*hTKW~C8$02nWou3*H|2Ds7k{MQN_Iai`25-J(grA10X^x@75b*Sx|_`> z({Q<@X^>RQLHHfHxfEy`pJZm5?o1RS(w*8}`}jR7yN}!r44%BU`8!johbR$eCkO!+ zA-NnhyN}PBV}W-HVP9QpzBEuB+d3ivz;$*Vh@ONKJ7=Ma)<4r<(78^^8SB(!Q+~z$ zkG-!hBwObkJ89sJp4h`&CnxSyTY?oE#_h{Tp&TKf>WgXX&`nmu&fv z!9C*;tzTdVc*-4l0Hj|BC`^H;15j=z-4vx~ZP;u4xs&YZ3p@CDSEPaBEy5NhaT1d0JNuNjj`!=J23v2V_+s#0ENFmhCToqv{G9){WGE zp!t4OUq;Fy9~DI{r&NtYtesfcGqzkZDm(Ne$`%40@Q zadhBH7&o)Fc!3_r?SeE$dIy^X@^lwx;53BR>SNY{{P|#a+J9qtht9ws^mQ}<#dUrT zj#g-#soXcxRzObyh8D*1`nWx^dq<{(EB)aoHjI;j#D<8zEfI1T7CF*?MYg7vRWdMb8qSY0CxH+i5m)c zz5cb;UV{y?YygOy8$A{T%UYxH&f_(8dRl(U0k`OpfBn2~a_gey3e8ohQ~p)(TNw_Y z#^+Y_C|)g(7M`z?c(;*F<+QtvJ(Hz3?H|$jaFXYr&mU-l1mq(ilyD+>{l;FkTV=Vc%uLQoCY!4fQmdVK zhe@h>sf}NMm;(ph(($1lpfmX2Q|w<~-IN2J*tg5BKd0dyt1ctgwU)1*UMChrEJ)lOq5^lH4XzZCyk*W6bCU3*c#V=kus@k zt;gU$LbLJf-`i{i(2SL)ZAs`q9FCCb`r=lyFWuwgOo%wb1kjN5$0sE@Hep7YGi4k& z_6-7@SO;$A+Xr6N^O+i_B&WCyo}$F!fh4aTn&M0X<&@_aV}yTb>IvzQ3h+URn&W7m zmjM-kEvfhW_2?U0CsfNK%F(7{z!ulC6A$<7OSY1F7hxbRn)i@eQ9vEapFS=#{>WU1 z8xqVu#3~y?0;-y}bF=6bqG;c7av{kOd@%BzKRv7XWmWM@rIaUmy)8fX=xRM~9_G@t z<<|Ey)c5}D8^&!?Dlbcc=e=XLwUP?euu6NHr*CA6MwBpI-V&`hcKQlLN!}k}^Z9lf zx(ZywN7T^I=;QhNJJY@T`@YrqTGPj$oJMMa3V80wD!PHS|Mz0K8ZqYWj`{k>QZKHj!5gtJ4A^)Y#8|hR;pT0RE*0CaAwk@5 zhT-O@JEm~a-*3QLvC6W|o%;73>si_!KT9;xzt&rPik?Yt-k(_oezYV`ww52go<_pA zq7Afkv!d_+FvgmC67>(hOPMrD`V!E~4`jX8qinX@<5mlE&@VW}kTK7s(xEi3_X#JG znX=qg?WpTjUgutpWCL`{<<9YA^j$rZzN7Os-gGZQEw_gH`d0Ek<``osIU!WKNhj zv-bl}gTB`}Iy#uw+sQ{v6>TMltfG{Z?urwL11YwW1b*($5p7Q;h;9w@Wx&NSFat-J z1MWXnJma^{P-$4J%Sg|uPJ9VRudDTU>Dt-j>Wn6JO&0U|*R+QeA9_0GOJig;B`1@R z;8EoUUyRVCUfJQ*2hI&b)_NsxKmi{^v1@sA@^CSa z(lkxWC`Y(#4=kSI-uzB)jz4>|w*P5q{`Q|Xet;eKg`IW9j7a@k{6Qm1*qN^@{4}Pb zgb!jy51jsOR2usK(WJV)5hKUw2Y0reERl&=7QFW;CJGf*SN^A-$Bm08&k{Qy)-k&U z4PEYNi@OCaOZwhfB2-ZzBlB+9j#K^}#ccJb5u?46{&>gZD`=}h*U2`&w)#W+ueNdD zoL$3wXkFG5pOUaD>ip9^2{7vGJ`<;#5AvqI5O;P@7ccBaZoQvVny7dDjVS;}I*!0& z`Nqg3Dd_1@E54TJn^j3Y+DG;SG_4wL>gq~+`xl6My~;A>{4xlgq>Ri1A__0p-pCU` zKCrvs@zN{rb}h{*7`cTU!t+5qhP1GBiMFif+8u)pvqeJT@KSg#()>87WO)| zLhYntedKyCX}()wlWFQSZDU13o+OT^W{nOteT6 z6Lo)BrTe-iA3hsBeXDh;9)f-9)P64NM1~cay(AeFd`#eYB&RJQuPPB^@a)7LW<>G@rtLetKpe z&ROs9!L_Ne+($FwKx^bl8PLwPGBpLAabTRn&g+9eTPNQ_O^jz!1!6prsH_UTyQXFx z@xdjPeUnwiKjEGh8xP_iQ2wE3+J*Q%_{z?vGRQ?yk$zgQUt=MON;NL6UWy^M=keSv zFsAw0(uytiimKWl_*7nua0w-FK<4tfblpLS&J=ASJ??$9)tBsblxPz|@p=(xOcI3W z7Zqitpi3wb4cp?2inze_%l*c-tW}k+TZvSe({?cvOQU9+2yK~S)XvYV7X#I-C*(-Jf6={ zArrIQYxBKwaXOD$$0_a0^(SC0U9<_w#4(CX;=SvmSCco|J8K^E89!>h{2m~-xZ1Sy zyD{NEUJUT%^o$n`=hJV|I+Y^lUTxFBf=}BUhk6S1f`bA;Ld)lztDG@kHEYg4yZaU* zjYk@KXz4{Yb|-GwKuLR&dwyQtCa9_Nx(>feHU{=ahYdR!0g6B&RnHSentyE!gyHhj zc)^S^+1pfpZ$J%M8WtVZNtdT)<U~;y23nm4D5l1qk*~9c};RghkwX(%KDRKiW%OG%=`DT4WP({gyyOe;PP;#U+=he z3o--hOKP_jXr4D*XL=2VUh!c<$n&ngpGO-*&Ctbq(9M~!{{=G&Cx=%4F(a^bS@fK* z8Uv2NfcgDjlVI>RhT$|=^u{IClSUPa?ABPL<39N%cf)@KGjvzmd@$jLjNR&6TqY+t z{;&ul12B${R#cKC-ayH`;i|0^ z6r+}RsMyWe&HG}$0z1wKQuYH#WOC!}sHV<;!l+|SJi%~Xmo@iH1%AQb7Z$N=+U2fq zV?wtGA2yY?4FE4?*X{063a|Ok4zn>m7L$v~eF7JInF!Cr4z7KU$Qy&q5J3;5oJKNG ziH|t=g-6lpewwWj!=#fwV-quS4sf>h;pbh5Q@}O8n9Dsmil6KgZHo>Qa;uR{+WLi7?n}a1m=IA4yDqwS+{}^;SZ(SvD*ltgl2!AfrG4!yz|_QgzRgYB zp70kN$55wKp9f*1QAG?xWi72UvK`b4fQG+Pa_yt|?B2=d*`+4RCSLNpRIAsH0buW& zkC)ej;IHNSPOW#?9zvH}D}#b$7RO3iFJ%PJa#RGD&4aNDA6V78URB_%q7@#cT51lHw|N*fU25~ zdPp7EIjG&`oHY@1Ub&@!_7PrUX;|IF*a!u1u;*?vYr{pdt!*3dAkovd($Rgvk*=L= z3EUG+N8)g`q0_5#q;h0PGWvyXU=M3Iz_d^=Y4B|>7Uw1~Wn#)Hu|*cF7`RJfuguRI zHJ4O?;3U8mkT%l&oCg2UFq!$E`*YQByi*$i7RJetv#-xpea{X$Vj>jyuHuq2doiul zGl_d`C(NcE3i~^)Bzj3lofZ7}@dN1Rsu)l@0^A!Y#u}jpZW1t@S;@w7mS+8_kQDVu zD+2`$b@v!=FOOQ(d>T;b?#y)9z|~MEZLhE&TL#ry`$2ACC}n0}e{uS9C0PXMTnXQ* zOw0OvCdXKQJd=ogwZx`P=R2a+O(m(J0?0ba(4#~H@5JW^G79|2^V~jxNUg0q+hci% z!wC(<$}+k8e%07jLh0XG*oB_Igxajk^3rxKf|<|U>>U1qiOw-1q>-3OpT)j&VFphC zxoHgfqRvT^)(K?4Ew)51q0qd*d4pL=sSL2JLCoMD)A;D4{=+s6x1H-9>g-9eNOgR?1fTz%j;N}tnH7S2=04Yh|DBA zkpPQpMNXsq(Fod*a&2;GJg66X108%IVT`qGWVEnst8ENkzk=YEKc+h})_^l+bmO$A4F78rZPW<(GS2qDf_lcugH zM6nWz6tpf7iw4om2Kh5j#~V)0%#)RuCc_ShnY<2TVe4c>7YGhu^4Xg${f*KMa4TXZ zcfR{fD5*roe2GrW9oW%%{SS2;8H?M8aTd#{1=>Ig+U>2Cvy$tN@J%~%T(^v{1pwY7{E7~NG=+&2i0<(_(C7&9*+NFYafM+u^vci^n@YvaP4%a4_ zvx0#G_em!iC9hFw%V#KR@8tJOu-xt&xE+zl5)M)QrujNVS+x@lkd=GhkpNK!4Mz(- zaype7>fECS;RN!)3t|dl*2NTEOK&_3rb4fYB5iXQXdhre_^0x!x1%uqs!F(SmRlzvG8J>4?dFDycw1^w`_V z?MCK-)b--tihjg%Euf+fj4-aE%qiJQ;eitV%D^rzqrRYnh$K;<$bc30DL5!d=*)4S z1aYCMsF?-nOqkV(@b*|vhmlHxPEFv5nCa)48ZI((E3EExm>Qx^I($c;P&@;dEK}ps z%4Yo6QEI0CY`t-%P4b87tZ3%A%aa3pQ$9hKZQ$UgTWYu9Zk{+;2sue1u8jP|9>=MK z%jz4Vfw!~>h$B|<_uE>2$^DHMv9v3&y`S6d&gpoCMMGQqff$sTeb1r?SVq1Ji@m|qRu%x47MPQW=5*%5=VG6y!1CT*MU zY}zQ}WdpXmd^_EN=1H85Ccl~58&1xL+LC&7Ak)t6)L)zqCd+LvM;XJo5r z`325cKyE9!E2=0e#wEa+=LVvr)^Iu>x&_{`_Ru8G4!IvJz!4L z3wjunq4opxb8JljkoT*1!TDi z2)eh4gl%8XP1n1D!QSCvnM8K>OpPmW1{jH|=&+dpKqhA%0AD@7do(<~Rz65r;hWu8luUxAIr7O-b9b(DKV+6Cwjnf_VF?x)&L zfYL@zAedhA1k%Pbj(JU^cSN4Sa|Lc8k9beIBg)rVNyh-`^O>-ihO%5jf+tfTI z+cWpo*?2iS#o0`a6IRrv$57i^s%^x3{}4?;l3+eCn{30m+(N|keJH4{osY?8FMBDg z{ig3RaNUd|VP$JE(+;cMCn8Bk8@S#ZHvu*X<_V5Xz{dqq;?mwA6#vGy?J4OPsfsI? z9>hJSck34GQI*A75RkgZHt4GSVOa7Euu_JfF6&6tWJLdLBL;ojV7VXvfn1OzIZY-s z5Ci6g2Y4oIahzDMG$mM15bkLq^Yxd8>i|`;@phLb{Znp}^xW#lg+5Ew-|o#id zeeE$Dtax;Ei;sja7()_=?C~?Ys2k%M%u~Qwt%MV%yt6ZqKLq$=!`a4shTB>lz>~oZ zDP&jg0c@0jS@4%y>ND-m?fs1%i=pZfQ<5Z}xnBc;9+ zbu%mKs$50L1J2Rj7slo)5GgQe&;LLTRLEt3NEC&8aR7Bs;5YG9H~O%%8Ea3vr5ts- z96jvjxBJ4P)}%Zi@S6`lnPRFYeqa&0vTy*JVwImye+>^mGEBxJeLvp#_KRtg9J+nJ z9QfSL8ZUDF`mh(Y149AV1D4#QBMtpc*61OSX_`2zIdPksaG=HsfIx&nz10la&V~6_ z_1CBzZADEq)Bt>6w(GY%n42QqYwuCQq;wRAjk?m3Cey5G2_CvN7ZYCh#2UYd8~O#bCDw zafZi2#|i>p1jJ!g``?|)>AF@yPK5;4qEyrJPlYC-Ti*8AF?r)vL&aZbTd)>9d%GiF zkANToF!&fq6+2FCcRvb_U2ORR-02RBTLgvsI?*u~lOE;(!&tTkl#8pP#?=<$?9qgRQ9_Rlzue z)vjy1Z8BlDvn5_#6^V(wCOi}Z981w;DA#`(9FOa8(QoA2GK(aF_c@tGTl_4gAHI4W zwI_QFo{|;Rl`Ti-0=5Iix4o(P*O_=F3bk)20qW1@kxu9EmAQk-Pg$X? zCC0G3i%(fq*T}Ibr2nvG;nzSjyJss;HK1R}7A$7d)6or9GTFyZNZ(FVX?LaA$omud z7tl|yHg;gMA9~V#-gbh#R>yzM<3 zOZce{Dk|#v*K4*d+9A4AOZh&zb8d#WIj0;1v?Z?ZGip^14ikdHE z85tS*Jh>IdBj5`BSJf4BN$zYd9*gxl!2K-+(6}emyki@hfXy@XgdD$OyyW+S>cc%y z7abu~Rcr?_Sqf$=oNkYbMqV?5saQ%n;o^Q(?;Q$|19n(_C1uO9-#sA&^olHT=X~uq zd~?5)s5${$%mqO`%-36z$6({10+}#6U#XQ=6Nrpp^nH{f!>*4O}p=?q4*WfE>yRZSpvFtLY^`t4Tv&Lav|F)?`I!SlCpNWiC$J z?AhwU3Pl6$hu6?n^gZzD6$BP~aK{yMmZ_t}LZDE#lvsc)Uc({Oxtr=r)jhY7i6<<5pEy z_eM4kPi?#NuN2v)`2u8_&%UYx(19l>#x*zeD)lq(xd1cc)2Sl}FbQ*ZX9e-lIzia= ztM%84!EFLIKZlyRyd+rs!Wjdcc9S{f3fy+8nxpg+>O6RV|62>FHARB_u-Ns|Y%2q2 zh2qyi8%UBLw_AvywW!c;gE)&O@6TGC3Sq#w%pUaL0EVhIlcx<}LkgtcajDFU0}1j+ za1;Ik1tOZoR*4m#+JK2N6~9BJ`?+D8-k}NF@Ttz<7{Ws4>;+- z?v~Bw18irHEvsfZ2;0PHHjsdw5dQPYOD6!|PezOXYe zG8)u)4~40CUpe)P=9|bHD@BI4-mc*MS{6{v^}s?r7;_OFh<4vU>ev! z>+E>#A0v}1Y$JF8i_KUPZ(~@Hv7>5v5kzoF?rY4kLxibuoL-?94}n3|a!sLtoEV7P zKCV;4t?{)htzM@Xuqp}2#1uX#TK%)bzHISY)5xevs+}@-Ja;nZ#oJ#Xz!@!=dXd%$ zq(VW;wxV}63eH&}yK0Zs6mPQv`nUBYNwxL|(OU`)hlN0!%*r8=-wsg)MaI;Cgs5bT zl%@rcb*e7ga%)dW!Id%K*aUi?`fJR`?(Nh|srFGT)XlR3gUi+3L#>0u_H_`KzuGd` zf$=uG8&C(gvbhN-GqUm(8tiO0jy3^fukBA<$x)Wgz|%@X@U?C8Ej_eI(t|C3oXtw$ z*$ON$VDHpFqO4*krI1S=RwtS4_TUT3>Nw zqYmhpkxzEJaA>DkC_U||1w}(a;3J0@9R)0+E&wI!f&&&9vSb1SQw}&!WqVr0R@ZKf zZlGxj_~iksRH9%>7z3h7n)wL}m1i%{!J<<8xH_HX0M%PnosYR+p9rqjup$VLjVj(} zvK_BZu1&KmB=NyIeh|F9i@P~guJYxyXt~g2k<)1W*;#rH5c%QhQZEP>F$NSUuxv2I zgwn@fEy=pDFsw%WQ0^t zhIY;F`W~9`+`=aO0l$-&w5Bunw^Ej*M9JXukN3`KwCdGHCqZLRcR&7-c-mj@TUK-C zOfZq7l!B~ZV(2e<)sJ|g!xYr2ys*=UbwH&n*HfL>i&qb?)wO;^OnlvceD^$lk=r^1 zlgL54ZjN+d1Qj-V8lYV>ttAZf#gdP%ZlBaPV z?drTyk9^gO^;`rMi)5yfn%Wh451(>M<|g_Z{H5w&M%#y!S9s>?Vq3$XF$`;oX)Jjn zPPubY)^YJMee!735x7Ce8=ggL0@k*jRwBK-_I}fyp;zOfxi}eLYV^k z-{TVyB(~fCY{&UKKK@uBE23)o))z;uOSGi=QIh8nBjS8Lf_X{D=&X%fxL8;9vwI8N z&>YvX)~n%{cJg_a*CQq6uYg> zXYz&;pT=7^N%C}0R_K%H-R>2a3%%*_!b*X=QzX)X^o+p3;)8Q1;%Klo4YXVU)fC{H zocDS^Ef~0jVmE|>u>^ST*V%z?G?_9+CGf8ur zf{1L>uttiLl1u#@E7=euCEJ<(+(FmKTaV`X!2gM1LyKOr69=_MhYr<0YQ5r(_IL|x ze!4o};)pA(*9#c35aHF+5sj_l#G*uHa$Dv$LV)_5zYkNH#SIHa;s_oEpYp%FX%qqa zm_AObnyM0}-0YDhh;tp{n;%4eT_}Imjf`|~HT~FrvsPRY%v3miFMUjffpy(;&(?Po zkZ_pO;bW%XGe-^DZT-E7a~7&;gqhqk4{)S}XOG?_V#j+l8AnColh6mPYPSh$81Gxy zZVNZtroy~BdI*>jU{|%PQ`L+cFDUQhe#{E);m?n@XOto2s=dCU-H{KUclW9u?(dM>_!gy_%@O z$_5b^c^nprzJ@8z!SdVi()B?ekAC)O;$Vm0W}4!f$hx9Zq*+jdqOs*1g6a6 z%Ny_7_=tS`tSG8Q_xzDC-3rs!H#`aonN{W)=@}!b)j2genb75fW6i?2^~@X(s|>f1 zN|=Ix0({8XIIUmvxg9;13Z{1bg4pR+dm&?n0eeK-O+6C`gutfN)+8a|r$nY;Exi~u z$5ar$07L7Ha=ZanZ=tgJ{ZW~rLKc$8TeZIR2fwjthQrbX#{y3SK+KDQC2d{yDm&?} zuwlBW_fTYyFXscbPHY#8JBFXKGHeOHOm6Y;MpWwuXW$#_-JNM+Z8|I8i(I={cg%p+ zsGnzuL^-JPR9SYqP-#wpLKB8hNS0VOlP{LbNL9+6>J+}$ez{HbWlC2`14^x9nlXk~ z0Z~Z(po-kqsTyS-uRGDL!xuC0+sCswx(GP;+v@(L{+kFqQb0#@mLFJ&D1u^+2=j#8 z@g~a=Cb7cvseAp2B?}9>nlLFeKp)v8+vv}~w<~AYlu4L@SoXfr{ixrGt%y1uQimBn z!nF2}&DoWmp5kGV26ZXY1CqfXVb;N)}5`2L8H>fc@>@7spL7%H%%@0Vb6 zVJbIn3!v!={NT`QFV~NmnGs3i+KefmGWtjIfvklCZ}Ttuaf}z>`6Xi}e^10D-n~g# zaB#_ZGNVW*q1X^5Qe$x6MT(=S&%FCXD<}Ym%3aQcipp>#GJh(-LjKXsDkFA&B|B7i zEB*EiEpOGgz}`pOjj#0J0iCBWeAQNDZy4(ZLBEvX=u(ym5|@#r$3cj4km>!(0oLsn zAd2Y^w~*$h*?Rl)_E#rEm1qNg>?d2pV%!ny-l-lh>oDaBCdCs*SH+6HtWs$b-%#lE z^LW*_)1NvU78m7>9N_iwc{T%Pd={Zxn3x>ya3PM(`}=O1EW`4{tb|#AM+5^|EVh;H zmgmN5ly5WEpQ+XP9#_7()2)Vo+@A`6KR!+@Le$%WNe5;IWbM2D@TOSR1LBIODEvOzO!m(g-J@LJssj^dSdZzG|~+m>r& zcf^ZMn2gwH?i-?K$h)?g4atb5&(Rb=QtNRi{gj`V(r1=fu*G*K-wAxVFP6!XDPGHn zs7}Bn7-@Q7x`v!i-09Wv?J+ATlK9NX5cGkHZX&y71QRT1qRaen#}0Wzx%WOS7AIXr zWOuh|SBn@^XC>bJDwcgF9Vp-4P3*cdFudp4aP-wygwe)l?$xkeRHkMCYVq`rJUUKV zZFx5MS$d{q+8ka(`CF8a{)Jl{Ov$>AqnTR`M!Umkj}=HWDV9 zL}YdRmy0x!vO%l$ExEuhl`7rkoqEdD$G;zWcr#!^pA!J7Ghy=-eOU@5*x1;b*1K&9%+Ot2$p`?6C zC;AKoa|T-v@BV(kYy*O{ahmZj&E4RC#ML?c9bB?GG1RYxGzuYhe*rkebWP*8Ah1 z1|RJFd?MLclZde*sH7d)nk}Y0tA4V**5mP_J?Cf}VboI^4Iw;qp`YHN8 zl@-XdDAT&%7a8THrsGM`ao)$S9q31h#oURPNBxfVLFUlQ<^;ZX@%T5SKJ$EjWjQI# z)0;iUl6=&4xIxU~es`$^$3;pidhzG4?N>kOR1;eQN@JxBPYQKi37;nw9xzpYCL>Zg zvlUa!JFxxhg(sR;6hj)0Ugb6tKSKkOGa{7a+jwH7RE4-{MLCc(xBY{i3^(jMw_Gw8 zP^K)=1`oar#W5Ktia)!*g&#b!*sQo@=~s16l)QD3-6kW*L5s@WS3b*oTwyh)<**w5 z&L6xpgDvGNCH%`Y=bq!ydEKmg9Pd)v5!VyRjLBTiX{>rn;_?U8^jYfE_ z$Vs$+G=V-tjKq*_u6I_Pk%g06p()J=!-(<6N(jUZ2==LPZJ@*$Uc+RUM(LdyfEVxXzn|MiLWwmh+Z#_9NYE6q$ z4oe=zWxBF`X-YIg#S2=V8(`~bw}kh#+-LmSLsPqN>?-(lw^O)^pL1b>sG!#`zl;7= zHcP*y9%bi}HCLg24Sgk<>U1G=>}Kjct?Y=*MDz8spu!t(KcSfYw>Z~oY+Dp2=nEe` zfojV?<&suk>Fi`&8Vl7kVDdI`?Uzwi`R!Est1qF(6BJ?z$YX|hj3;k{>-Rf|tN%J+ z;Y-{P?I7xZcHw`Yk;q>ZONUw2?kRG7)3K`8pjur?DKR?wdu(`4zw!pui93O~GTjDK6qF;7O!*yq zDGK7DflJQ+e)aD>+}G1Sz697XAn}kDq@2I5ik^SE?=^{eDeW4bk2jK%2hYjK&}VUG zX86VNJ*P5!>GvvNo%O}_E3Zb?*8f;HC`4lyO2pO-p zv=jH>xdgb;^-F0KL~$p|yThg`A&^wA${KOh{a+4h3M=fUim@yuissc2X?E}g@)Ex)$XJ#1mFTst9ik`;5%W^cv)n8BlQWho`c=q-PtE?1 z@W}aJ&F_xdRNBu}L8f}WKD9-fHL8K^%q>~*Q3d6V3Ln$z06YSQ97~2#_pwS`p5gPl38U~ae|z)QceA@Q%qAR# z#i>f(PYkR1c9r{=Gx+RNwr&SvQpc`shp~dC$XoV@B0Yj2>!q)LFl48vlQEaS3*j|K zui!>#Yq_=HqT>ql7H0Xgo&cc@V@_D1Z|~sPPggPTZ9t7e*@hE&okVDOQ@3%E`BmNr zqkRU@(#28RojCut3;`q_1^2~KC56H1Zo)mdXH(G;K9?OA2S>bIMtvPrlgZyX%(-fD zr;(gHPr!Fj2x%qf)%Z5%m7i^fqXnVdC4*ehP`~@p=D^WNOp>$=?*3zo+kutU)yG$; zWjv1ZBL?HiOO-a(AABknC&x9pp~>8g=-Y{=6UslHnI?iaM>ak8wAWQgu3n zFq#?kb25cT*fKIO)@_)}#;?wl+OXUF5%z1&)}kKz+vk0R)JadLr>8I7PUb&pB<+xj zW(`ZaFu9D*iJOd~%`|)xhBN`y5BFpf?4`DzDZ9#CNFha%y%&TkCM=PEC|@RqMf~hORFNUYeKcOSkRm>6|0;L1K~1Pj>~&)q5;z#tu<&a6n$u@TaHO<0yT}&(D2+)_MoX=Q9dTzlZSN zeXv@yJ2xRv?LrVfd2nWU_L7Qlvj?O%&Ue=x!*%2q6t!4}n_ttous?NkC77r%C5kd2 z7kEx$CY$`my~(Sd6?hErS8awHG}O<}FKuM@CMCbI3u#IsdhlZBE2}{beW`j7M-_zi zvR!O%$qtLF`u29v5#g6O|963MS%f)Ivy9A47I)_Y9Q)W<(*0(<9d%lTWP{6&<(%~= zXSSwlM;Ti+$(N%>h)eFo-oE3fk>wm!p4%5oR|j2O!N?P>i`BPDx-p4~XP<;%1I&zF z#7<@s)tEqiwaJd)P7{D^)W(KElhDWB=e`QXI;u8A3}u0%ygGJ88nT&vNhjVqnu`4u z5u+;`7t0I*+`Urfz4~;8dD2qF@`>EN_PW2@+A_QhJn47dEmgKFB<#`f<}>74Gcste zXw%XC>Ct$7=YaJwN!N%Z~=mHR!G$=zO0{B9@bu_BcCh2dB$nhkt&0I+3FR zF#ZXzvU`73A52oQT%EAVv?KU7@oDe`n{)q1-;3G->bqeSJgOFz(49ucF`4rx7d`+h z5oTH&JbR~BE1G@UDvOWPG|YhoKM&bPW4bNKTiJA5xZd36f( zE0=oZx9!r`Bx^pb&lGS=vfu?X&TscLJZf)Ta!xlTzOKp4%=&Q2bPy#1}!tI;2{ zfoL){A0`m z9?31(@;(h6EOD&*784T@ZFN*20?_E&(o2?jdw>Wz%JRG8JsI>T*eonjIGc;s$>cL%=43t3xO*f>-^P_Q+`<{Uv!e}0eK)gMu6#voV`7b4dVT5u*TB*C z$fZ5(w2rv4SivvH(+!-_&x*wo=$aHRa(}Im4$*W_>9~tWF@15@=*(K?D5pXS3t3B&BG=U@ zn2EG}#mtJc%4wwevE7$0^ClW-Ij!T zqkJy56B47-vsz~zu}(3Im6qAreS+nuryWjgGis{pSLci*+e^)*r%?YYt=2T^1RBO1*KjycB_)-Aa-YgogEuPW#xIgjLNa9MuW?sX2sr^dM<0McWd1hvWkWy$SXb$yW8+s3R1#jdi5>e|6 zNi8ZWXIUY=w{$phQ1aEDpf4?}=zZI8U>7l~;yA4T$g%Oj{jya?<7y9fkyi_66A-ur z!9xPY&gx4d3e{|qd@qlU}Hoh8S9Zrb>mD2;8@#oz9=2v2hkMU_Mu zDXEUG9)f)KkCknlu{f~Z%b2@|8w+588G3E6-ZDN56 z#j7r)jrjCba6<;%b<6C2i8B34wVm3A<^2(|q^L9r9IaxZzW=QkfI3a9SL3d7JdfO) zsUfbbakMpckyCTo+PONN`#bL~L(;RM9Lf6TJAni0?a9f`6yI(-Yl9#?p`NyOXNmZM zZ}eYIJ5(MA8tHVt84mN#zkJQkx>M`Ko+;;HGr+%dK4Q(aGYb*+udcXDqmV$xY4XoTOrcrs8iC`I*_)93+V*>sl5YW}>a;zAB%k^K$PBHr>Yr86{&fcjcl0zv z1h$vQ$-dy5pM!vj5+4`mxBglQ-&pBV8stlBP+e*dZH9}Ft~Fj$=19vmcj`glg)b#&e!^2d=|nD;*b?myc|7w5GV^L4AoT* zaPbZ6#~iq&?Z1Z) zjfqb-B9f9u$H(7n*DHBD$n#zt1?H^B{RCR|Cr#U(_Og*=l{#rD=dYDkv5Acb{5Mzc z)eYDA(w2p|&<`*1Hr$nu+4$X9IA%L(SvtXQeShC6*`U5mVTYoA>U@h`fcYqvmgFnB zfage@T#4q2YvpovX~U^rWr27pb=#h4lV-MEyH@2h0_nIRv~<#I^|wX=krYkU&CMvHzdllmOqoX+ zQ1X>qZWM&Sy@#BQNox3DDYtRqX0*85*u35QBhzsOY$O~UN4~F><~5bO*KJ;7yNE)I zSvGTL@3M7~^Lwi5)3R~9kLq+s-!qqwJ@`BP``o2J*!HyF=hnSLOXlsZ*_{vEO9V1@ zQS}F?C&8_Qm5?wgaHc&KeCJ*Na9$5HxK($$!Cs>1>~4;=6^mXyi*ai0k6X>D7XPcf z@BU}Id*gPuMW+@mRV`|zZp5lRt5g-CTGg0EP^&gIf-ZX$MI*M_HDkx4nY9?ZD z65=`feO}K$@%$u_&p4m+Ip;dp^?tw4b$Q+0e>I`PCv&z(Rx0N0y0?0t&YBWlUmzc) zag_1x=E{7?l5vd-$VEAqe0m^&>q!!F?XO(;@uSxG?sSp4B@+3m)2xG``#?SFXOgA* zP59)>1Qr{=KZ$5IvI&E>Yh|f%hnmw0*xFth|7Bq_U3>7S07<>Vd8(7}Dv1wL)*x)r^jPY@;azKCfuzKTos%zv;obf#k@3aS6oSftBx_A_7t2Qo) z>0wd5)0oXqkyJCJSKCZTlSWRWCtg#^M-d2fPuyvfcgzG zD#fom-&N#J@PBdFF*2(1n)v|WgqfrAG^e5QuB1hi$5iFA+#}U%>pj}IZgZ0Qy%zL7TVrC@7{cvJ+;D!)*&^$?#_fUKBTj6z75<`9{zB<=UHQwA@W|S@ zmx2!xW0@=#AHH%B73whKv*dVm%k8l_O~B50n10%xk$A1j!RA2kvX>W+FYP&kcl2Y^ z;e-CbXNIHKlz&upPjK__Eca1fpO$x3oRO0~CL5W9+6muh6)879h}n{DSHf$j_f#1| zCO^d|wn}iH|8czgV3R9gU}$iVQ(j56=cza~HmyboaM~#^p0kQA7{(4P2ETV^Y6hDb z>aC|T-w4E$cYKep^ogAdw%tLCpStwexHB3dm#t=g8;*+K*r%0Cg}v#}&sZ$myXKT? zdGPyTa!%FDcPtqx71)c@m0y4~$E zG&D}SDSi?gmn-yqS2^v)Mv@XErY=rVnHX%VZ%9@8@<{!SKGX)Sqm&iF`H?)RA*PS% zf--`*_<9fW7p5dLvm5h=^op4ot+aBHH?6uHiYJry*ZV5$?&=3LJU3huM0GJSS*aGU z(E)wQjaOc>EbI|B#jm5H_=MZCXE+@))Xhz@E*_K$L5q|z?lp_>>wdZ1c{geMSEL&tI=+bV8>lb!5TEdx9Gc(-fs{d;`iO!!T>2O0JLK>W4qF#NN5x($)GoXRV zrBTiBV}hW>s#U+yq86nEMwNW|!pkd8C39ID8NEE^IZTI28q5*DV#C7934}YTU#8YK z_eWoLhk+RuZTzp>vIy%`)KS%J@I06u_k!x$8sigeZOUz?oUfK7K(DzV8jF919;QUz z-_mWViz~KbEB!X_{Y1P9v)R{@?6X@Om^8!XIKQ;U%vEt7{lzmtw{Ys)>G-{HjuseC z=kxUz)8ea4qw*pmhT!sz$!_Ap3a%MQWnuvss4onH&2L$p`g}-d0 zO*P!GQ|;FWxeSh=bInw1|3x&J!iu$12KW$RP};|dT2mg{Em4WPjs27y{xp}jOgFpS zSIMR$NuLz^km>%Ei5UvU+wIGAwhSOagJ1YF^P8#~#bRUe=UKA{#9M=2FV+S!GnZ1! zgZ@o&_-8E?i5F}J(TrT$a8zP|F{yNLFySX()mLN|qhy zigwv6Unr~ClRoG4DbE~ezbzcRbSn{}-lncsBBqPJ zcL@(uCiW|MOuW6bq90Gc#fa;)h%R&GyXmhO^z6_muK2pE;#6|sx|K%ir;JQHjtKw- zQJAW}5RS}w>?4^F)gf&VocbZxtVJ@UiLVE;)4j~@1`+zGl{K;%qsJWb^oskVgBv$J zKv4GUZS{du)FmaKHfBjC!a^LjDPOBB{q!e6r!sIi8gW>3bZ@h1w##ZZ?!v>?2m zdSc)kQs=-Z3k5U9?|abUFz?4}^D^D{-{okyc*ZP$j!cCqz4Q(FgA>kn0DbD`#(0uI zt;m54XbH_k+PYC9%X%6DBd>ikeOo{uX@N=cooqbA4Gu@vKeSZxL0aD=`ui(onE@QR z7L3J`Idpj9zqblle}h;|fsg5qt?2+iW@`TKtYA_Z&M!6ci^eNfHk}kgh77DHYxt7hjTD(=iH3)(XBIQuKt;J{8uu0)AJy6kKyQ z&T8G%+1B*+Il5gfA`hw`J{T<1EudeMqPfg?72NuFPynYzuN|j*)+Nqd96t1-YxSyF zya5hN{b&#TPI1uBwbs}JGcDJ@%{d+e4XIQN?Z=gwM=fu6==VGb)|l(JFWn)JQdyIucXL*bPsKT8$DNWH*C zq$myU`Jol7SNHE-VJ`KdzpQ z!#gJaA`}r48ZPWxA+CM%XKGLdwspGM4!${N-$HOTKgNodP5OFKDS=>6qLP9FPV=bA zE37-D{tfuzqBnV!5;8+C|R^MLrGcZXk(kl>R;YYa1YRUapk4NkDeLDkw{$bf@gb z%8K{)YuYf{{?)e5)&-Ip+_L|0a2ciTu#baBN||cAsXNsFa{e8iCtk3Y*Sy5eVNA`S zOmeAI=0&`^`?=<)f0S^_e=on<#;3bfFp}|LtP~J38N4*r3jA24-ly$o zJD=Rv>$NEvvnCK06fsk?a5IePMEqKnB>YUc>ew?hS4YUrwXvch{%fT{j|yJnFOZ9s z;g$?!vD{?lX8rv}xcg*QGw*cS7c9@ypFFu#0RlPha~Xp&28@qiQo?U!o6a&L&eY>z zbgMO*M>9vJ_*~!Yu)IXNK(`@@hDF|Ec-Fz^aI0A!S=9Y*#3b-Xf63)DEGQnWah8Zo zLA*rDeGESK-d$ZyJHcpyCkFB=$>_raz_|+Mu$7`kY{ofuEZ}?$@D$SVuef^Rn)Kct zty}AUk*N5{@NmbM($Z3dO$m4yKetEr`vp~b=+7MSUh<#WA5ZD%#wgsB>Tm{D4m&j@ z>grK7c3{-W-LqKe16&6WaspG8P5=SdA>oO{2>)NXT%XgzCu>T<{1b$21& zRo||BN#1v_O0Kq=S_~vz&$N{>??(c+mv@>}S=_edR>fzLxda*=m{^qDs$0#>!7?UX z@KRh%!m!}?&()N|b!m5Ez3=u1gTwt@xTXe*wp1g%wyLx|)7XU3gu)|gK>bzb!@#RS zuunu{b8x#gYFKXKq{+D>?!hsHZ8KxZ0&9>MWxAKQ5ym39>m?wMnL82f@wRb{gHrnF z-&(?ju}VMtJb%V|S8C5V;N-=Zt#Uq5?^kes=?P$3wgNny3oxV<7Z+|6pbPFq9unf? z$@?c2-LaF0D=I8rF1v@np6}YiNcr)@1re@d`n|FSg}H}YB;U%{dfO2lNdP341MSSk zbn>XnvHU}qNR_TaGgfTlYXxsArnf9G6j&a>1`zG?Y^K=nWdM@*_;tn%f4qo2{=;7C$0IPLB%$tuZFgShNm34Gf z^(mMHI2?+%x+~>6SoG(DJMnh%ueX|Yb)lEd5E+@|?FnD8k~rT{esFt%b}XJ7pJCpRc! z@RUk9HGW{x(|3r9X?DTM@l7FlnGa^91@N3n&Z@%%~4z6yZK0EI>cZRE|yulWBfGht1Wf5()e8?kjP$V%G z&mPLDDIEmkrArhu4Big71|`q+jtCDTk*k9>0qY0IvNnsnoctG~2C@0Inpn!A-t5^v zeI}gT>$0s54#81>}sha z;vh*n;m+D|KNA=&I>{|M8%yShlQ^!Kcmo*;s;Pkmt2juqR@S8;81s?4_YB@7piXMZ zsp>kbqz}L=>I#8Gcj*Fp3aecD@ZrNM189}U6VG_XFer!N+C}-jy)gTFEaBzEyfc_L zGoGunP-XYa{?Xi_vHv-pWF`s^*6w57S@j>I+P5indNj_X)pj|IgvmvSa1F zwc6T;BHG&Iz2&&`*cbmkD9k&VIGCw6oR=>NnOG~APV}t+b&q(_i8MXGrIR-dkfZf2 zDDS=ceE4!ji4Cl~US`iUm4MqCjE2bTf5ks!>$7?_S+5CjM?mL6c?)ZN=3_V10M5!? ze@;BI3~NlbXo|@bM})nb{Ybr$EcQt0(fUGTjx(>WUG~pv=O7wZDThGK`!N5CDoj=f zB@^K;4URfboJH1$IF=Q=ut@N2JA;x1X=ZaBnWONgZTx!AY;0sXUQn z-dz0+mW-7^5)K1-Ef#O-Y#=MwJFC>{UQFL35)Sv!6-GYfHkPT4&Kq-=TmdXnQk*~L zLtJf2KHeBXjC(sYBl3pM_Kw_8be(IFG^RwsqeB8XDLY@^C~k-on9R<-@g%DLBfE2^ zpA={HQyCfe%*_33VHzc`mf*2XwbexbRlY9Y^a?fJ;dhhVd3NvIZDK*8L_l}8#PJ;> z8ivzbdM2w4$pgvt>l6QJlch~2AHZSW^J_7})MBDz!|z7x<}oK23-wszoSbI4Az&B2 zrdUsj^?lgoc|hFyZO+6~y=Qhzx~_XW5x-FAr{Jz`_br%HBfgxF6-0c*iKxMmvxYM(H>!J ztwo+V9l+}A`Db%P`0}v$!vc~^fUS(nrXN6enO;wdw<#(!1Mww|ZpcKS(DQR`q-sJl zRlo#SoU`@-$m2`79#wztD5U7SOCGPAEoXJMgdtSl`Ot<8Fg0l01ZLGiDk52ix5LJy zBFel1AbwJAbLyC$`%%%+7Ci}4?)xtG79!}bD@)zq+U%OVY!Giq#L;DtB&R1?qyZab zdo;PtRRXO5OOLR6`>A)OK>>YMcbfp%_l9h*gv3Gx(N%B2yHm2AOL&VWtl3X{b%=;h zX(szP?icm_e%hz`h0(f1`ctd1QhCtU^OyJgl)FX77f=`z=vAg<06udwYQOjimMYOQ zS0G9#%+I?%vDI%YW?1Pm#EjF{{u$jIVYJ)&c-iU^xl)CO^*C9Yebih zg7#bj;J&BCJ-=`JmzVH!o4Zq1@ukHRYpgys%RA?dJv{ko zn6>$wr;b8&70cYK420jdHJ7+>W8eqxlut-|az5LuM_1wmjBB4y%B!m&;NNxz7Z-fI z=VylRkpQ)w9A%1}9WzII9Y{DF;y9OqWT=9q&}(02$m&&Hqjl$f$dm4_cLz$4O*jE|xk&uO2mE>O=ksI4?PG$= zSwg}4uX#V4Abxt7`kNcr(z^G{xdwQ>mk%%r>gF%Fr`-vi9{OO)%Tg2fmaU!(4gQ!~ zKRlSJ)rW%=cf=o{>o9ux8e-_5q>RkhiQU6evyG1FK%yvaKQT3m@R|qPF8Pzhor#UV zh-mdF-f4Xf-p}PrODedYGzAc1{b?DRW4Nbh{`h1#T19lPx<*Tn-L#B{`u>zpRgTtss!BnLyzQfGvzO=M6aqv=749FONpqW_YG(-jWn5bOD_#nM9 zGyHkNlcjy(+zUF-B}JJU?zLGk5z5y_btmg1AkcMr2TmTz<+{EG zFhP+2^BGoDk-#T&3Ii=w@K)1+k=M`fxS%B2xOjL3I6CajF$2$sv;@YRU#M0PXX3@i z?aDMX&TR!NWdXG(892qP%2RWk271EF${pS3%bdCmNX z=E{hJ8Uv-4^CHbxH|)tD-TY!fAQo&*`RB6AH))h4GR@#M)?^}0g(XVoQ4i4!(&LR> zEme>5CAVXMTL8_=o-8dZ^IZx)kRx~KCR_dlz$S$NM$mPY(7Tz0JjYrw0qT9s953@% zZLSW&XA(t6?4!lq-g(%Op3v%VGu}@DGVo6y&J)iN|LnV33!8B*5s4DKjDV1|?E1cn z9xD+R0sO$)R7`TE!MdRrO>07?7BAKnTl3?`URy0lMdQBl>W8M>1gM@?(15URzke)t9Np;A;-~>bT$HRFhq|{(W=0|(XNMW z9A^XucE0~0)bsXlvn-#TX9{@PTvbxiehsb9Y88#{^lK2fmnkw37ZfWl6-=$vi8cYowkBCvV)5(brGeYwD z**gtH(30J*2>lXJl(lvtMvDr;Nl~{1`^0Mh8*DQ>KhgTSY#~x|q#&-R>!;d-(o%|8fBz>vBJU+7$EGi}JC|URe4P5u{9(*o7`h_wUmLs^ zfQ8xM8nMkZgUcnQ1xt$~r}nC@8SVRpb~D+TpBDe$qV)HfMWqtQY`b`;jX7+L)A;>m zt9EC9-1s8Ol*WP*?|9+H%b$1HWKVVgDY>+r+`&NmiprE>?s`Th3 z`(XN$EC>@0;kpjkH77|xKvylQ#WX~@Cq%`@#^&b6C!Y|y%+;1O6CV!K*AiBkBks!b zFba$GLqh9uFTU0!*!D@SReBAS%L0K@M^DG4$*O@xDPVYkx7TjbrmA{E+TXU>?EPtV z1`x<;OXJrZXMRHkI=|nuzEe#vUQmbG5UCQhUqE8~!!DQ6+hcCyv+FN%IAprKFf=4> zjq@iF5`Nam)*&8^R{eu;@^T^bRsFdHq*Em7Jh9BIjI(9X0EI%4#ZPB=&kU^RiN!|J zl(#sLulTyTW%A7!);XQjlXrTQBx=3fH%2P5&W2#x)o2|TrT;B5`^yw(Pumb9c4k9`}ACc{am_EOQ>-?(UcK2|z zhPpZQ4uND{m^78C!GaU7eKZ24)Py1>`1y6Z?k}(anG<{zz5#ChMhMkLw+16$zP+Li z24FQx;+>0yMRWN*65?O9v{&`-o6bI+7MP!dU=X+en@qe0Xd*eV&z*@w_I&aMb0IWo ze;NMrdm*_CT+GF3Y-x}x&M@Vq)%b&-HD~qPYj=Cafd?Zg2@1BD{WpmME=xGuBM%7m zNTanzxGjY`IO{WzSMxd!UA|EHo>U%q>4^7+R+tiFKJf9jPKHH#fQLsx_p+pD;roE~ zq=up{<1@AH8M4J2XY|1HF|7@_oYP`d0~5mMjXiTfNA2k`idpBD;+GGG*jw()(6sV{ z)f@q664Q8XT+^hK7%5H{`cvNNDf9=ozO%LnP_MRG3QK7`1^YlLe#vgDTZt;-lq%*I zwzOWZXdOy`cDt z)b+-ef!pIZWvPhh#69+;Z@MqN7(8736#*~IcW=*tJ|C{9tk0qoM)S+*?uI&PV&77_A^;nj)B10wEzBB zL6ud_$TKUs^B3E8WBUN_4NAH*xG<&BRQN!x9WuBKOVai*U0l8ucrFV7Wnj<0>UWa< z_Oee7hllVYj|O{$QkV#Uq9{H?)KpKRFW-Kk%((rqJzCWj#@P8cReAQ%Kor`dWZGrO z#ovPv`i3EsCHC%<@8<#V)22?I(5)0K11ljCm&g~+c*Nf$D65I8TA8DWv4PIo5L4HLA)mr2@q_AO8;>*o#d7 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 @@ +