mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
Merge pull request #6127 from Wei-Shaw/feat/oauth-transport-plugin-system
feat: add OAuth outbound transport plugin system
This commit is contained in:
@@ -49,6 +49,9 @@ coverage/
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# 本地闭源插件目录可能包含发布签名私钥,绝不能进入 Docker 构建上下文。
|
||||
/plugins/
|
||||
|
||||
# Local config
|
||||
config.yaml
|
||||
config.local.yaml
|
||||
|
||||
+11
-1
@@ -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/
|
||||
|
||||
@@ -153,6 +153,11 @@ func runMainServer() {
|
||||
log.Fatalf("Failed to initialize application: %v", err)
|
||||
}
|
||||
defer app.Cleanup()
|
||||
if app.PluginManager != nil {
|
||||
if err := app.PluginManager.Start(context.Background()); err != nil {
|
||||
log.Printf("Plugin manager started in degraded state: %v", err)
|
||||
}
|
||||
}
|
||||
if app.PromptAudit != nil {
|
||||
if err := app.PromptAudit.Start(context.Background()); err != nil {
|
||||
// Startup continues so unrelated APIs stay up. Fail-closed (unavailable)
|
||||
|
||||
+26
-10
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -95,6 +95,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
nil, // ollamaCloudUsage
|
||||
nil, // auditLog
|
||||
nil, // promptAudit
|
||||
nil, // pluginManager
|
||||
)
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
|
||||
+11
-6
@@ -24,6 +24,8 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/google/wire v0.7.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/go-hclog v1.6.3
|
||||
github.com/hashicorp/go-plugin v1.8.0
|
||||
github.com/imroc/req/v3 v3.59.0
|
||||
github.com/klauspost/compress v1.18.2
|
||||
github.com/lib/pq v1.10.9
|
||||
@@ -54,6 +56,8 @@ require (
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/term v0.44.0
|
||||
google.golang.org/grpc v1.82.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.44.3
|
||||
@@ -121,12 +125,14 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
|
||||
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||
github.com/icholy/digest v1.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
@@ -149,6 +155,7 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/oklog/run v1.1.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
@@ -187,10 +194,9 @@ require (
|
||||
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.uber.org/atomic v1.10.0 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
@@ -200,8 +206,7 @@ require (
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/grpc v1.75.1 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
+45
-26
@@ -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=
|
||||
|
||||
@@ -32,7 +32,7 @@ const (
|
||||
|
||||
// DefaultCSPPolicy is the default Content-Security-Policy with nonce support
|
||||
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware
|
||||
const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src 'self' https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
|
||||
// UMQ(用户消息队列)模式常量
|
||||
const (
|
||||
@@ -99,6 +99,18 @@ type Config struct {
|
||||
Idempotency IdempotencyConfig `mapstructure:"idempotency"`
|
||||
BatchImage BatchImageConfig `mapstructure:"batch_image"`
|
||||
ImageStorage ImageStorageConfig `mapstructure:"image_storage"`
|
||||
Plugins PluginConfig `mapstructure:"plugins"`
|
||||
}
|
||||
|
||||
// PluginConfig 控制管理员手动上传的本地进程插件。
|
||||
// 默认不包含插件,也不允许安装未签名插件;TrustedPublishers 用于追加第三方发布者。
|
||||
type PluginConfig struct {
|
||||
DataDir string `mapstructure:"data_dir"`
|
||||
AllowUnsigned bool `mapstructure:"allow_unsigned"`
|
||||
TrustedPublishers map[string]string `mapstructure:"trusted_publishers"`
|
||||
MaxUploadBytes int64 `mapstructure:"max_upload_bytes"`
|
||||
MaxUncompressedBytes int64 `mapstructure:"max_uncompressed_bytes"`
|
||||
StartTimeoutSeconds int `mapstructure:"start_timeout_seconds"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
@@ -2269,6 +2281,14 @@ func setDefaults() {
|
||||
viper.SetDefault("pricing.update_interval_hours", 24)
|
||||
viper.SetDefault("pricing.hash_check_interval_minutes", 10)
|
||||
|
||||
// 本地进程插件。插件必须由管理员手动上传,项目默认不携带任何插件能力。
|
||||
viper.SetDefault("plugins.data_dir", "")
|
||||
viper.SetDefault("plugins.allow_unsigned", false)
|
||||
viper.SetDefault("plugins.trusted_publishers", map[string]string{})
|
||||
viper.SetDefault("plugins.max_upload_bytes", int64(128*1024*1024))
|
||||
viper.SetDefault("plugins.max_uncompressed_bytes", int64(256*1024*1024))
|
||||
viper.SetDefault("plugins.start_timeout_seconds", 15)
|
||||
|
||||
// Timezone (default to Asia/Shanghai for Chinese users)
|
||||
viper.SetDefault("timezone", "Asia/Shanghai")
|
||||
|
||||
@@ -2625,6 +2645,15 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("security.proxy_probe.urls: %w", err)
|
||||
}
|
||||
c.Security.ProxyProbe.URLs = proxyProbeURLs
|
||||
if c.Plugins.MaxUploadBytes <= 0 || c.Plugins.MaxUploadBytes > 1024*1024*1024 {
|
||||
return fmt.Errorf("plugins.max_upload_bytes must be between 1 and 1073741824")
|
||||
}
|
||||
if c.Plugins.MaxUncompressedBytes < c.Plugins.MaxUploadBytes || c.Plugins.MaxUncompressedBytes > 2*1024*1024*1024 {
|
||||
return fmt.Errorf("plugins.max_uncompressed_bytes must be between max_upload_bytes and 2147483648")
|
||||
}
|
||||
if c.Plugins.StartTimeoutSeconds < 1 || c.Plugins.StartTimeoutSeconds > 120 {
|
||||
return fmt.Errorf("plugins.start_timeout_seconds must be between 1 and 120")
|
||||
}
|
||||
if c.Server.ReadHeaderTimeout < 1 || c.Server.ReadHeaderTimeout > 60 {
|
||||
return fmt.Errorf("server.read_header_timeout must be between 1 and 60 seconds")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const pluginUISessionTTL = 30 * time.Minute
|
||||
|
||||
// PluginHandler 提供插件安装、生命周期、配置和隔离 UI 资源接口。
|
||||
type PluginHandler struct {
|
||||
manager *service.PluginManager
|
||||
}
|
||||
|
||||
func NewPluginHandler(manager *service.PluginManager) *PluginHandler {
|
||||
return &PluginHandler{manager: manager}
|
||||
}
|
||||
|
||||
func (h *PluginHandler) List(c *gin.Context) {
|
||||
plugins, err := h.manager.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, plugins)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Get(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
plugin, err := h.manager.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, plugin)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Upload(c *gin.Context) {
|
||||
maxBytes := h.manager.MaxUploadBytes()
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+(1<<20))
|
||||
file, header, err := c.Request.FormFile("plugin")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "请选择有效的 .s2plugin 文件")
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
if !strings.HasSuffix(strings.ToLower(header.Filename), ".s2plugin") {
|
||||
response.BadRequest(c, "插件包扩展名必须是 .s2plugin")
|
||||
return
|
||||
}
|
||||
var installedBy *int64
|
||||
if subject, ok := middleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 {
|
||||
userID := subject.UserID
|
||||
installedBy = &userID
|
||||
}
|
||||
plugin, err := h.manager.Install(c.Request.Context(), file, installedBy)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Created(c, plugin)
|
||||
}
|
||||
|
||||
type pluginEnableRequest struct {
|
||||
AcceptUntested bool `json:"accept_untested"`
|
||||
RolloutPercent int `json:"rollout_percent"`
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Enable(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
request := pluginEnableRequest{RolloutPercent: 100}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.BadRequest(c, "启用参数无效")
|
||||
return
|
||||
}
|
||||
plugin, err := h.manager.Enable(c.Request.Context(), id, request.AcceptUntested, request.RolloutPercent)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, plugin)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Disable(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
plugin, err := h.manager.Disable(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, plugin)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Delete(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.manager.Delete(c.Request.Context(), id); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"message": "插件已卸载"})
|
||||
}
|
||||
|
||||
func (h *PluginHandler) GetConfig(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
configJSON, err := h.manager.GetConfig(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", configJSON)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) SaveConfig(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 4*1024*1024))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
response.BadRequest(c, "插件配置必须是有效 JSON")
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
response.BadRequest(c, "插件配置只能包含一个 JSON 值")
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "插件配置无法序列化")
|
||||
return
|
||||
}
|
||||
saved, err := h.manager.SaveConfig(c.Request.Context(), id, raw)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", saved)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Test(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.manager.Test(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) CreateUISession(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
assetToken, expires, err := h.manager.CreateUIAssetToken(c.Request.Context(), id, pluginUISessionTTL)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
bridgeToken, err := randomPluginToken()
|
||||
if err != nil {
|
||||
response.InternalError(c, "创建插件 UI Bridge 失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{
|
||||
"url": fmt.Sprintf("/api/v1/plugin-ui/%s/index.html#bridge_token=%s", assetToken, bridgeToken),
|
||||
"bridge_token": bridgeToken,
|
||||
"ui_bridge_version": 1,
|
||||
"expires_at": expires,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeUIAsset 使用短时随机能力 URL 提供插件静态资源,不向 iframe 暴露管理员凭据。
|
||||
func (h *PluginHandler) ServeUIAsset(c *gin.Context) {
|
||||
token := strings.TrimSpace(c.Param("token"))
|
||||
pluginID, err := h.manager.ResolveUIAssetToken(token)
|
||||
if err != nil {
|
||||
c.Status(http.StatusGone)
|
||||
return
|
||||
}
|
||||
relative := strings.TrimPrefix(c.Param("path"), "/")
|
||||
data, logicalPath, err := h.manager.ReadUIAsset(c.Request.Context(), pluginID, relative)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
contentType := mime.TypeByExtension(filepath.Ext(logicalPath))
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.Header("Referrer-Policy", "no-referrer")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "SAMEORIGIN")
|
||||
// sandbox iframe 没有 allow-same-origin,会以不透明来源加载自己的 CSS/JS。
|
||||
// 资源 URL 由短时随机能力 Token 保护,Bridge Token 只存在于 fragment 中。
|
||||
c.Header("Cross-Origin-Resource-Policy", "cross-origin")
|
||||
c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; navigate-to 'none'")
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
func pluginIDParam(c *gin.Context) (int64, bool) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.BadRequest(c, "插件 ID 无效")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func randomPluginToken() (string, error) {
|
||||
buffer := make([]byte, 32)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buffer), nil
|
||||
}
|
||||
@@ -382,9 +382,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
ModelPlazaDescription: settings.ModelPlazaDescription,
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
PluginManagementEnabled: settings.PluginManagementEnabled,
|
||||
ModelPlazaDescription: settings.ModelPlazaDescription,
|
||||
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
|
||||
|
||||
@@ -347,6 +347,9 @@ type UpdateSettingsRequest struct {
|
||||
ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaDescription *string `json:"model_plaza_description"`
|
||||
|
||||
// Plugin management menu visibility switch; plugin runtime is unaffected.
|
||||
PluginManagementEnabled *bool `json:"plugin_management_enabled"`
|
||||
|
||||
// Affiliate (邀请返利) feature switch
|
||||
AffiliateEnabled *bool `json:"affiliate_enabled"`
|
||||
|
||||
@@ -1938,6 +1941,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.ModelPlazaDescription
|
||||
}(),
|
||||
PluginManagementEnabled: func() bool {
|
||||
if req.PluginManagementEnabled != nil {
|
||||
return *req.PluginManagementEnabled
|
||||
}
|
||||
return previousSettings.PluginManagementEnabled
|
||||
}(),
|
||||
AffiliateEnabled: func() bool {
|
||||
if req.AffiliateEnabled != nil {
|
||||
return *req.AffiliateEnabled
|
||||
@@ -2357,9 +2366,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
|
||||
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
|
||||
|
||||
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
|
||||
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
|
||||
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
|
||||
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
|
||||
PluginManagementEnabled: updatedSettings.PluginManagementEnabled,
|
||||
|
||||
AffiliateEnabled: updatedSettings.AffiliateEnabled,
|
||||
|
||||
|
||||
@@ -316,9 +316,10 @@ type SystemSettings struct {
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
// Model Plaza feature (public group/model pricing showcase)
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaDescription string `json:"model_plaza_description"`
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaDescription string `json:"model_plaza_description"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
|
||||
// 风控中心功能开关
|
||||
RiskControlEnabled bool `json:"risk_control_enabled"`
|
||||
@@ -418,8 +419,9 @@ type PublicSettings struct {
|
||||
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
|
||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ type AdminHandlers struct {
|
||||
UserAttribute *admin.UserAttributeHandler
|
||||
ErrorPassthrough *admin.ErrorPassthroughHandler
|
||||
TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
|
||||
Plugin *admin.PluginHandler
|
||||
APIKey *admin.AdminAPIKeyHandler
|
||||
ScheduledTest *admin.ScheduledTestHandler
|
||||
Channel *admin.ChannelHandler
|
||||
|
||||
@@ -111,8 +111,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
PluginManagementEnabled: settings.PluginManagementEnabled,
|
||||
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ func ProvideAdminHandlers(
|
||||
userAttributeHandler *admin.UserAttributeHandler,
|
||||
errorPassthroughHandler *admin.ErrorPassthroughHandler,
|
||||
tlsFingerprintProfileHandler *admin.TLSFingerprintProfileHandler,
|
||||
pluginHandler *admin.PluginHandler,
|
||||
apiKeyHandler *admin.AdminAPIKeyHandler,
|
||||
scheduledTestHandler *admin.ScheduledTestHandler,
|
||||
channelHandler *admin.ChannelHandler,
|
||||
@@ -76,6 +77,7 @@ func ProvideAdminHandlers(
|
||||
UserAttribute: userAttributeHandler,
|
||||
ErrorPassthrough: errorPassthroughHandler,
|
||||
TLSFingerprintProfile: tlsFingerprintProfileHandler,
|
||||
Plugin: pluginHandler,
|
||||
APIKey: apiKeyHandler,
|
||||
ScheduledTest: scheduledTestHandler,
|
||||
Channel: channelHandler,
|
||||
@@ -117,6 +119,7 @@ func ProvideGatewayHandler(
|
||||
|
||||
func ProvideOpenAIGatewayHandler(
|
||||
gatewayService *service.OpenAIGatewayService,
|
||||
pluginManager *service.PluginManager,
|
||||
concurrencyService *service.ConcurrencyService,
|
||||
billingCacheService *service.BillingCacheService,
|
||||
apiKeyService *service.APIKeyService,
|
||||
@@ -128,6 +131,7 @@ func ProvideOpenAIGatewayHandler(
|
||||
cfg *config.Config,
|
||||
coordinator *securityaudit.Coordinator,
|
||||
) *OpenAIGatewayHandler {
|
||||
gatewayService.SetPluginManager(pluginManager)
|
||||
h := NewOpenAIGatewayHandler(gatewayService, concurrencyService, billingCacheService, apiKeyService,
|
||||
usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, cfg)
|
||||
h.securityAuditCoordinator = coordinator
|
||||
@@ -267,6 +271,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewUserAttributeHandler,
|
||||
admin.NewErrorPassthroughHandler,
|
||||
admin.NewTLSFingerprintProfileHandler,
|
||||
admin.NewPluginHandler,
|
||||
admin.NewAdminAPIKeyHandler,
|
||||
admin.NewScheduledTestHandler,
|
||||
admin.NewChannelHandler,
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
type pluginRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewPluginRepository(db *sql.DB) service.PluginRepository {
|
||||
return &pluginRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pluginRepository) List(ctx context.Context) ([]*service.PluginInstallation, error) {
|
||||
rows, err := r.db.QueryContext(ctx, pluginSelectSQL+` ORDER BY installed_at DESC, id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
plugins := make([]*service.PluginInstallation, 0)
|
||||
for rows.Next() {
|
||||
plugin, scanErr := scanPlugin(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
plugins = append(plugins, plugin)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, plugin := range plugins {
|
||||
bindings, bindingErr := r.listBindings(ctx, plugin.ID)
|
||||
if bindingErr != nil {
|
||||
return nil, bindingErr
|
||||
}
|
||||
plugin.Bindings = bindings
|
||||
}
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
func (r *pluginRepository) GetByID(ctx context.Context, id int64) (*service.PluginInstallation, error) {
|
||||
plugin, err := scanPlugin(r.db.QueryRowContext(ctx, pluginSelectSQL+` WHERE id = $1`, id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plugin.Bindings, err = r.listBindings(ctx, plugin.ID)
|
||||
return plugin, err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) GetByKey(ctx context.Context, key string) (*service.PluginInstallation, error) {
|
||||
plugin, err := scanPlugin(r.db.QueryRowContext(ctx, pluginSelectSQL+` WHERE plugin_key = $1`, key))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plugin.Bindings, err = r.listBindings(ctx, plugin.ID)
|
||||
return plugin, err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Install(ctx context.Context, plugin *service.PluginInstallation, bindings []service.PluginBinding) (*service.PluginInstallation, error) {
|
||||
manifestJSON, err := json.Marshal(plugin.Manifest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化插件清单: %w", err)
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO sub2api_plugin_installations (
|
||||
plugin_key, name, version, description, author, manifest, artifact_data,
|
||||
artifact_path, install_path, binary_path, binary_sha256,
|
||||
signature_status, state, last_error, installed_by, installed_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13, '', $14, NOW(), NOW())
|
||||
ON CONFLICT (plugin_key) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
version = EXCLUDED.version,
|
||||
description = EXCLUDED.description,
|
||||
author = EXCLUDED.author,
|
||||
manifest = EXCLUDED.manifest,
|
||||
artifact_data = EXCLUDED.artifact_data,
|
||||
artifact_path = EXCLUDED.artifact_path,
|
||||
install_path = EXCLUDED.install_path,
|
||||
binary_path = EXCLUDED.binary_path,
|
||||
binary_sha256 = EXCLUDED.binary_sha256,
|
||||
signature_status = EXCLUDED.signature_status,
|
||||
state = EXCLUDED.state,
|
||||
last_error = '',
|
||||
installed_by = EXCLUDED.installed_by,
|
||||
installed_at = NOW(),
|
||||
enabled_at = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE sub2api_plugin_installations.state IN ('disabled', 'error', 'incompatible')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sub2api_plugin_bindings b
|
||||
WHERE b.plugin_id = sub2api_plugin_installations.id AND b.enabled = TRUE
|
||||
)
|
||||
RETURNING id
|
||||
`, plugin.PluginKey, plugin.Name, plugin.Version, plugin.Description, plugin.Author, manifestJSON, plugin.ArtifactData,
|
||||
plugin.ArtifactPath, plugin.InstallPath, plugin.BinaryPath, plugin.BinarySHA256,
|
||||
plugin.SignatureStatus, plugin.State, plugin.InstalledBy)
|
||||
var id int64
|
||||
if err := row.Scan(&id); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, service.ErrPluginStateChanged
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := replacePluginBindings(ctx, tx, id, bindings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (r *pluginRepository) GetArtifact(ctx context.Context, id int64) ([]byte, error) {
|
||||
var artifact []byte
|
||||
err := r.db.QueryRowContext(ctx, `SELECT artifact_data FROM sub2api_plugin_installations WHERE id = $1`, id).Scan(&artifact)
|
||||
return artifact, err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Delete(ctx context.Context, id int64, expectedBinarySHA256 string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
DELETE FROM sub2api_plugin_installations p
|
||||
WHERE p.id = $1 AND p.binary_sha256 = $2 AND p.state NOT IN ('starting', 'enabled')
|
||||
AND NOT EXISTS (SELECT 1 FROM sub2api_plugin_bindings b WHERE b.plugin_id = p.id AND b.enabled = TRUE)
|
||||
`, id, expectedBinarySHA256)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 1 {
|
||||
return service.ErrPluginStateChanged
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pluginRepository) BeginEnable(ctx context.Context, id int64, binarySHA256, expectedState string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE sub2api_plugin_installations
|
||||
SET state = 'starting', last_error = '', updated_at = NOW()
|
||||
WHERE id = $1 AND binary_sha256 = $2 AND state = $3 AND state <> 'starting'
|
||||
`, id, binarySHA256, expectedState)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 1 {
|
||||
return service.ErrPluginStateChanged
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pluginRepository) MarkRuntimeHealthy(ctx context.Context, id int64, binarySHA256, configEncrypted string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE sub2api_plugin_installations p
|
||||
SET state = 'enabled', last_error = '', enabled_at = COALESCE(enabled_at, NOW()), updated_at = NOW()
|
||||
WHERE p.id = $1 AND p.binary_sha256 = $2 AND p.config_encrypted = $3
|
||||
AND EXISTS (SELECT 1 FROM sub2api_plugin_bindings b WHERE b.plugin_id = p.id AND b.enabled = TRUE)
|
||||
`, id, binarySHA256, configEncrypted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 1 {
|
||||
return service.ErrPluginStateChanged
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pluginRepository) UpdateState(ctx context.Context, id int64, state, lastError string, enabledAt *time.Time, expectedBinarySHA256, expectedState string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE sub2api_plugin_installations
|
||||
SET state = $2, last_error = $3, enabled_at = $4, updated_at = NOW()
|
||||
WHERE id = $1 AND binary_sha256 = $5 AND state = $6
|
||||
`, id, state, lastError, enabledAt, expectedBinarySHA256, expectedState)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 1 {
|
||||
return service.ErrPluginStateChanged
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pluginRepository) UpdateConfig(ctx context.Context, id int64, encrypted, expectedBinarySHA256 string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE sub2api_plugin_installations
|
||||
SET config_encrypted = $2, updated_at = NOW()
|
||||
WHERE id = $1 AND binary_sha256 = $3
|
||||
`, id, encrypted, expectedBinarySHA256)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 1 {
|
||||
return service.ErrPluginStateChanged
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pluginRepository) UpdateBindingsAndState(
|
||||
ctx context.Context,
|
||||
pluginID int64,
|
||||
bindings []service.PluginBinding,
|
||||
state string,
|
||||
lastError string,
|
||||
enabledAt *time.Time,
|
||||
expectedState string,
|
||||
expectedBinarySHA256 string,
|
||||
) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE sub2api_plugin_installations
|
||||
SET state = $2, last_error = $3, enabled_at = $4, updated_at = NOW()
|
||||
WHERE id = $1 AND ($5 = '' OR state = $5) AND binary_sha256 = $6
|
||||
`, pluginID, state, lastError, enabledAt, expectedState, expectedBinarySHA256)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 1 {
|
||||
return service.ErrPluginStateChanged
|
||||
}
|
||||
if err := replacePluginBindings(ctx, tx, pluginID, bindings); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type pluginBindingExecutor interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
func replacePluginBindings(ctx context.Context, executor pluginBindingExecutor, pluginID int64, bindings []service.PluginBinding) error {
|
||||
if _, err := executor.ExecContext(ctx, `DELETE FROM sub2api_plugin_bindings WHERE plugin_id = $1`, pluginID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
if _, err := executor.ExecContext(ctx, `
|
||||
INSERT INTO sub2api_plugin_bindings (
|
||||
plugin_id, capability, platform, account_type, enabled, rollout_percent, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
`, pluginID, binding.Capability, binding.Platform, binding.AccountType, binding.Enabled, binding.RolloutPercent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const pluginSelectSQL = `
|
||||
SELECT id, plugin_key, name, version, description, author, manifest,
|
||||
artifact_path, install_path, binary_path, binary_sha256,
|
||||
signature_status, state, config_encrypted, last_error,
|
||||
installed_by, installed_at, enabled_at, updated_at
|
||||
FROM sub2api_plugin_installations`
|
||||
|
||||
type pluginScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanPlugin(scanner pluginScanner) (*service.PluginInstallation, error) {
|
||||
plugin := &service.PluginInstallation{}
|
||||
var manifestJSON []byte
|
||||
if err := scanner.Scan(
|
||||
&plugin.ID, &plugin.PluginKey, &plugin.Name, &plugin.Version, &plugin.Description,
|
||||
&plugin.Author, &manifestJSON, &plugin.ArtifactPath, &plugin.InstallPath,
|
||||
&plugin.BinaryPath, &plugin.BinarySHA256, &plugin.SignatureStatus, &plugin.State,
|
||||
&plugin.ConfigEncrypted, &plugin.LastError, &plugin.InstalledBy, &plugin.InstalledAt,
|
||||
&plugin.EnabledAt, &plugin.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(manifestJSON, &plugin.Manifest); err != nil {
|
||||
return nil, fmt.Errorf("解析插件清单: %w", err)
|
||||
}
|
||||
return plugin, nil
|
||||
}
|
||||
|
||||
func (r *pluginRepository) listBindings(ctx context.Context, pluginID int64) ([]service.PluginBinding, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, plugin_id, capability, platform, account_type, enabled,
|
||||
rollout_percent, created_at, updated_at
|
||||
FROM sub2api_plugin_bindings WHERE plugin_id = $1 ORDER BY id
|
||||
`, pluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
bindings := make([]service.PluginBinding, 0)
|
||||
for rows.Next() {
|
||||
var binding service.PluginBinding
|
||||
if err := rows.Scan(&binding.ID, &binding.PluginID, &binding.Capability, &binding.Platform,
|
||||
&binding.AccountType, &binding.Enabled, &binding.RolloutPercent,
|
||||
&binding.CreatedAt, &binding.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bindings = append(bindings, binding)
|
||||
}
|
||||
return bindings, rows.Err()
|
||||
}
|
||||
|
||||
var _ service.PluginRepository = (*pluginRepository)(nil)
|
||||
@@ -0,0 +1,70 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPluginRepositoryLifecycleIsAtomicAndOptimistic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := &pluginRepository{db: integrationDB}
|
||||
pluginKey := "local.test.repository-" + strings.ToLower(time.Now().Format("150405.000000000"))
|
||||
defer func() {
|
||||
_, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_installations WHERE plugin_key = $1`, pluginKey)
|
||||
}()
|
||||
|
||||
manifest := service.PluginManifest{SchemaVersion: 1, ID: pluginKey, Name: "测试插件", Version: "1.0.0"}
|
||||
first := &service.PluginInstallation{
|
||||
PluginKey: pluginKey, Name: "测试插件", Version: "1.0.0", Manifest: manifest,
|
||||
ArtifactData: []byte("first-package"), ArtifactPath: "/tmp/first.s2plugin",
|
||||
InstallPath: "/tmp/first", BinaryPath: "/tmp/first/plugin",
|
||||
BinarySHA256: strings.Repeat("a", 64), SignatureStatus: service.PluginSignatureTrusted,
|
||||
State: service.PluginStateDisabled,
|
||||
}
|
||||
bindings := []service.PluginBinding{{
|
||||
Capability: service.PluginCapabilityOpenAIOAuthOutbound,
|
||||
Platform: service.PlatformOpenAI, AccountType: service.AccountTypeOAuth,
|
||||
RolloutPercent: 100,
|
||||
}}
|
||||
installed, err := repo.Install(ctx, first, bindings)
|
||||
require.NoError(t, err)
|
||||
artifact, err := repo.GetArtifact(ctx, installed.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first.ArtifactData, artifact)
|
||||
|
||||
require.NoError(t, repo.BeginEnable(ctx, installed.ID, first.BinarySHA256, service.PluginStateDisabled))
|
||||
bindings[0].Enabled = true
|
||||
now := time.Now()
|
||||
require.NoError(t, repo.UpdateBindingsAndState(
|
||||
ctx, installed.ID, bindings, service.PluginStateEnabled, "", &now,
|
||||
service.PluginStateStarting, first.BinarySHA256,
|
||||
))
|
||||
|
||||
second := *first
|
||||
second.Version = "1.1.0"
|
||||
second.Manifest.Version = second.Version
|
||||
second.BinarySHA256 = strings.Repeat("b", 64)
|
||||
second.ArtifactData = []byte("second-package")
|
||||
_, err = repo.Install(ctx, &second, bindings)
|
||||
require.ErrorIs(t, err, service.ErrPluginStateChanged)
|
||||
|
||||
bindings[0].Enabled = false
|
||||
require.NoError(t, repo.UpdateBindingsAndState(
|
||||
ctx, installed.ID, bindings, service.PluginStateDisabled, "", nil, "", first.BinarySHA256,
|
||||
))
|
||||
replaced, err := repo.Install(ctx, &second, bindings)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, installed.ID, replaced.ID)
|
||||
|
||||
err = repo.Delete(ctx, replaced.ID, first.BinarySHA256)
|
||||
require.True(t, errors.Is(err, service.ErrPluginStateChanged))
|
||||
require.NoError(t, repo.Delete(ctx, replaced.ID, second.BinarySHA256))
|
||||
}
|
||||
@@ -96,6 +96,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewUserGroupRateRepository,
|
||||
NewErrorPassthroughRepository,
|
||||
NewTLSFingerprintProfileRepository,
|
||||
NewPluginRepository,
|
||||
NewChannelRepository,
|
||||
NewChannelMonitorRepository,
|
||||
NewChannelMonitorV2Repository,
|
||||
|
||||
@@ -994,6 +994,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"model_plaza_enabled": false,
|
||||
"model_plaza_require_auth": false,
|
||||
"model_plaza_description": "",
|
||||
"plugin_management_enabled": false,
|
||||
"risk_control_enabled": false,
|
||||
"cyber_session_block_enabled": false,
|
||||
"cyber_session_block_ttl_seconds": 3600,
|
||||
@@ -1305,6 +1306,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"model_plaza_enabled": false,
|
||||
"model_plaza_require_auth": false,
|
||||
"model_plaza_description": "",
|
||||
"plugin_management_enabled": false,
|
||||
"risk_control_enabled": false,
|
||||
"cyber_session_block_enabled": false,
|
||||
"cyber_session_block_ttl_seconds": 3600,
|
||||
|
||||
@@ -53,6 +53,9 @@ var requiredCSPDirectiveValues = []struct {
|
||||
directive string
|
||||
value string
|
||||
}{
|
||||
// 插件配置 UI 使用同源 iframe;目标响应仍必须显式放开 X-Frame-Options,
|
||||
// 因此这里只允许 'self' 不会使其他默认 DENY 的管理/API 页面可被嵌入。
|
||||
{"frame-src", "'self'"},
|
||||
{"script-src", CloudflareInsightsDomain},
|
||||
{"script-src", TencentCaptchaDomain},
|
||||
{"frame-src", TencentCaptchaDomain},
|
||||
@@ -196,35 +199,41 @@ func directiveHasValue(policy, directive, value string) bool {
|
||||
// addToDirective adds a value to a specific CSP directive.
|
||||
// If the directive doesn't exist, it will be added after default-src.
|
||||
func addToDirective(policy, directive, value string) string {
|
||||
// Find the directive in the policy
|
||||
directivePrefix := directive + " "
|
||||
idx := strings.Index(policy, directivePrefix)
|
||||
|
||||
if idx == -1 {
|
||||
// Directive not found, add it after default-src or at the beginning
|
||||
defaultSrcIdx := strings.Index(policy, "default-src ")
|
||||
if defaultSrcIdx != -1 {
|
||||
// Find the end of default-src directive (next semicolon)
|
||||
endIdx := strings.Index(policy[defaultSrcIdx:], ";")
|
||||
if endIdx != -1 {
|
||||
insertPos := defaultSrcIdx + endIdx + 1
|
||||
// Insert new directive after default-src
|
||||
return policy[:insertPos] + " " + directive + " 'self' " + value + ";" + policy[insertPos:]
|
||||
}
|
||||
}
|
||||
// Fallback: prepend the directive
|
||||
return directive + " 'self' " + value + "; " + policy
|
||||
if end, ok := cspDirectiveEnd(policy, directive); ok {
|
||||
return policy[:end] + " " + value + policy[end:]
|
||||
}
|
||||
|
||||
// Find the end of this directive (next semicolon or end of string)
|
||||
endIdx := strings.Index(policy[idx:], ";")
|
||||
|
||||
if endIdx == -1 {
|
||||
// No semicolon found, directive goes to end of string
|
||||
return policy + " " + value
|
||||
trimmed := strings.TrimSpace(policy)
|
||||
if trimmed == "" {
|
||||
return newCSPDirective(directive, value)
|
||||
}
|
||||
if !strings.HasSuffix(trimmed, ";") {
|
||||
trimmed += ";"
|
||||
}
|
||||
return trimmed + " " + newCSPDirective(directive, value)
|
||||
}
|
||||
|
||||
// Insert value before the semicolon
|
||||
insertPos := idx + endIdx
|
||||
return policy[:insertPos] + " " + value + policy[insertPos:]
|
||||
func cspDirectiveEnd(policy, directive string) (int, bool) {
|
||||
start := 0
|
||||
for start <= len(policy) {
|
||||
end := len(policy)
|
||||
if relativeEnd := strings.IndexByte(policy[start:], ';'); relativeEnd >= 0 {
|
||||
end = start + relativeEnd
|
||||
}
|
||||
fields := strings.Fields(policy[start:end])
|
||||
if len(fields) > 0 && fields[0] == directive {
|
||||
return end, true
|
||||
}
|
||||
if end == len(policy) {
|
||||
break
|
||||
}
|
||||
start = end + 1
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func newCSPDirective(directive, value string) string {
|
||||
if value == "'self'" {
|
||||
return directive + " 'self';"
|
||||
}
|
||||
return directive + " 'self' " + value + ";"
|
||||
}
|
||||
|
||||
@@ -132,6 +132,25 @@ func TestSecurityHeaders(t *testing.T) {
|
||||
assert.Equal(t, 1, countDirectiveValue(csp, "worker-src", TencentCaptchaWorkerSource))
|
||||
})
|
||||
|
||||
t.Run("old_custom_policy_dynamically_allows_same_origin_frames", func(t *testing.T) {
|
||||
cfg := config.CSPConfig{
|
||||
Enabled: true,
|
||||
Policy: "default-src 'self'; frame-src https://checkout.example.com",
|
||||
}
|
||||
middleware := SecurityHeaders(cfg, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/admin/plugins", nil)
|
||||
|
||||
middleware(c)
|
||||
|
||||
csp := w.Header().Get("Content-Security-Policy")
|
||||
assert.Equal(t, 1, countDirectiveValue(csp, "frame-src", "'self'"))
|
||||
assert.Equal(t, 1, countDirectiveValue(csp, "frame-src", "https://checkout.example.com"))
|
||||
assert.Equal(t, "DENY", w.Header().Get("X-Frame-Options"))
|
||||
})
|
||||
|
||||
t.Run("api_route_skips_csp_nonce_generation", func(t *testing.T) {
|
||||
cfg := config.CSPConfig{
|
||||
Enabled: true,
|
||||
@@ -298,6 +317,21 @@ func TestEnhanceCSPPolicy(t *testing.T) {
|
||||
assert.Contains(t, enhanced, CloudflareInsightsDomain)
|
||||
})
|
||||
|
||||
t.Run("allows_only_same_origin_plugin_frames", func(t *testing.T) {
|
||||
policy := "default-src 'self'; frame-src https://checkout.example.com"
|
||||
enhanced := enhanceCSPPolicy(policy)
|
||||
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", "'self'"))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", "https://checkout.example.com"))
|
||||
assert.NotContains(t, enhanced, "frame-src *")
|
||||
})
|
||||
|
||||
t.Run("adds_same_origin_frame_source_once_when_directive_is_missing", func(t *testing.T) {
|
||||
enhanced := enhanceCSPPolicy("default-src 'self'; script-src 'self'")
|
||||
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", "'self'"))
|
||||
})
|
||||
|
||||
t.Run("does_not_duplicate_nonce_placeholder", func(t *testing.T) {
|
||||
policy := "default-src 'self'; script-src 'self' __CSP_NONCE__"
|
||||
enhanced := enhanceCSPPolicy(policy)
|
||||
@@ -449,6 +483,15 @@ func TestAddToDirective(t *testing.T) {
|
||||
assert.Contains(t, result, "script-src")
|
||||
assert.Contains(t, result, "https://example.com")
|
||||
})
|
||||
|
||||
t.Run("does_not_match_a_directive_name_suffix", func(t *testing.T) {
|
||||
policy := "default-src 'none'; child-frame-src https://legacy.example.com"
|
||||
result := addToDirective(policy, "frame-src", "'self'")
|
||||
|
||||
assert.Equal(t, 1, countDirectiveValue(result, "frame-src", "'self'"))
|
||||
assert.Equal(t, 0, countDirectiveValue(result, "child-frame-src", "'self'"))
|
||||
assert.Equal(t, 1, countDirectiveValue(result, "child-frame-src", "https://legacy.example.com"))
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
|
||||
@@ -20,6 +20,9 @@ func RegisterAdminRoutes(
|
||||
settingService *service.SettingService,
|
||||
panelRateLimiter *middleware.PanelRateLimiter,
|
||||
) {
|
||||
// 插件 UI 使用短时能力 URL,仅提供经过安装校验的静态资源。
|
||||
v1.GET("/plugin-ui/:token/*path", h.Admin.Plugin.ServeUIAsset)
|
||||
|
||||
admin := v1.Group("/admin")
|
||||
admin.Use(gin.HandlerFunc(adminAuth))
|
||||
// 面板全局按用户限流(默认管理员豁免,可在系统设置中关闭豁免)
|
||||
@@ -100,6 +103,9 @@ func RegisterAdminRoutes(
|
||||
// TLS 指纹模板管理
|
||||
registerTLSFingerprintProfileRoutes(admin, h)
|
||||
|
||||
// 本地进程插件管理
|
||||
registerPluginRoutes(admin, h, stepUpAuth)
|
||||
|
||||
// API Key 管理
|
||||
registerAdminAPIKeyRoutes(admin, h)
|
||||
|
||||
@@ -736,6 +742,22 @@ func registerTLSFingerprintProfileRoutes(admin *gin.RouterGroup, h *handler.Hand
|
||||
}
|
||||
}
|
||||
|
||||
func registerPluginRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAuth middleware.StepUpAuthMiddleware) {
|
||||
plugins := admin.Group("/plugins")
|
||||
{
|
||||
plugins.GET("", h.Admin.Plugin.List)
|
||||
plugins.GET("/:id", h.Admin.Plugin.Get)
|
||||
plugins.POST("/upload", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Upload)
|
||||
plugins.POST("/:id/enable", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Enable)
|
||||
plugins.POST("/:id/disable", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Disable)
|
||||
plugins.DELETE("/:id", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Delete)
|
||||
plugins.GET("/:id/config", h.Admin.Plugin.GetConfig)
|
||||
plugins.PUT("/:id/config", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.SaveConfig)
|
||||
plugins.POST("/:id/test", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Test)
|
||||
plugins.POST("/:id/ui-session", h.Admin.Plugin.CreateUISession)
|
||||
}
|
||||
}
|
||||
|
||||
func registerChannelRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
channels := admin.Group("/channels")
|
||||
{
|
||||
|
||||
@@ -146,6 +146,7 @@ type AccountTestService struct {
|
||||
cfg *config.Config
|
||||
settingService *SettingService
|
||||
tlsFPProfileService *TLSFingerprintProfileService
|
||||
pluginManager *PluginManager
|
||||
agentIdentityTaskMu sync.Mutex
|
||||
agentIdentityWS agentIdentityWSConnectionInvalidator
|
||||
// grokWSDialer is optional; realtime account tests use the default OpenAI-style
|
||||
@@ -159,6 +160,12 @@ func (s *AccountTestService) SetSettingService(settingService *SettingService) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AccountTestService) SetPluginManager(pluginManager *PluginManager) {
|
||||
if s != nil {
|
||||
s.pluginManager = pluginManager
|
||||
}
|
||||
}
|
||||
|
||||
// NewAccountTestService creates a new AccountTestService
|
||||
func NewAccountTestService(
|
||||
accountRepo AccountRepository,
|
||||
@@ -787,7 +794,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, true)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@@ -2124,7 +2131,7 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, true)
|
||||
if err != nil {
|
||||
if s.accountRepo != nil {
|
||||
updates := buildOpenAICompactProbeExtraUpdates(nil, nil, err, false, time.Now())
|
||||
@@ -3022,7 +3029,7 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, false)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Responses API request failed: %s", err.Error()))
|
||||
}
|
||||
|
||||
@@ -517,6 +517,10 @@ const (
|
||||
// the Model Plaza page (global pricing notes, exchange rate, promotions, ...).
|
||||
SettingKeyModelPlazaDescription = "model_plaza_description"
|
||||
|
||||
// SettingKeyPluginManagementEnabled controls sidebar visibility only; it does
|
||||
// not stop or otherwise change already loaded plugin runtimes.
|
||||
SettingKeyPluginManagementEnabled = "plugin_management_enabled"
|
||||
|
||||
// SettingKeyUpstreamBillingProbeSettings stores the global enable switch and interval
|
||||
// for probing remote Sub2API API-key billing metadata.
|
||||
SettingKeyUpstreamBillingProbeSettings = "upstream_billing_probe_settings"
|
||||
|
||||
@@ -74,7 +74,7 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co
|
||||
}
|
||||
|
||||
upstreamStart := time.Now()
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(req, proxyURL, account)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
|
||||
@@ -157,7 +157,7 @@ func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch(
|
||||
SetActualOpenAIUpstreamEndpoint(c, "/v1/responses")
|
||||
|
||||
upstreamStart := time.Now()
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(req, proxyURL, account)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
|
||||
|
||||
@@ -470,15 +470,21 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont
|
||||
req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
|
||||
resp, err = s.httpUpstream.Do(req, request.proxyURL, request.accountID, request.accountConcurrency)
|
||||
} else {
|
||||
client, clientErr := httpclient.GetClient(httpclient.Options{
|
||||
ProxyURL: request.proxyURL,
|
||||
Timeout: codexModelsManifestRequestTimeout,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
})
|
||||
if clientErr != nil {
|
||||
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_PROXY_INVALID", "invalid proxy configuration: %v", clientErr)
|
||||
handled := false
|
||||
if s.pluginManager != nil {
|
||||
resp, handled, err = s.pluginManager.RoundTripOpenAIOAuth(reqCtx, req, request.proxyURL, request.credentialAccount)
|
||||
}
|
||||
if !handled {
|
||||
client, clientErr := httpclient.GetClient(httpclient.Options{
|
||||
ProxyURL: request.proxyURL,
|
||||
Timeout: codexModelsManifestRequestTimeout,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
})
|
||||
if clientErr != nil {
|
||||
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_PROXY_INVALID", "invalid proxy configuration: %v", clientErr)
|
||||
}
|
||||
resp, err = client.Do(req)
|
||||
}
|
||||
resp, err = client.Do(req)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, &codexModelsManifestUpstreamError{
|
||||
|
||||
@@ -92,7 +92,7 @@ func (s *OpenAIGatewayService) ForwardEmbeddings(
|
||||
if account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
setOpsUpstreamError(c, 0, safeErr, "")
|
||||
|
||||
@@ -222,7 +222,7 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest(
|
||||
if account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
|
||||
if account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic(
|
||||
return nil, fmt.Errorf("build upstream request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func (s *OpenAIGatewayService) ForwardResponsesInputTokens(
|
||||
if account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
setOpsUpstreamError(c, 0, safeErr, "")
|
||||
@@ -323,7 +323,7 @@ func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic(
|
||||
if account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
setOpsUpstreamError(c, 0, safeErr, "")
|
||||
|
||||
@@ -938,7 +938,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
|
||||
// Send request
|
||||
upstreamStart := time.Now()
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if headerGuard != nil && headerGuard.stopHeaderWait() {
|
||||
if resp != nil && resp.Body != nil {
|
||||
|
||||
@@ -110,7 +110,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
|
||||
return nil, buildErr
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
|
||||
@@ -1329,7 +1329,7 @@ func (s *OpenAIGatewayService) describeGrokComposerImage(
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
return "", OpenAIUsage{}, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
|
||||
}
|
||||
|
||||
@@ -607,7 +607,7 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses(
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
|
||||
}
|
||||
|
||||
@@ -375,7 +375,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
return nil, fmt.Errorf("build grok retry request: %w", err)
|
||||
}
|
||||
}
|
||||
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func (s *OpenAIGatewayService) forwardAnthropicViaNativeAnthropicEndpoint(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
}
|
||||
|
||||
upstreamStart := time.Now()
|
||||
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if err != nil {
|
||||
// Transport-level failure (proxy/DNS/TCP/TLS — no HTTP response). Convert to
|
||||
|
||||
@@ -122,7 +122,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic(
|
||||
return nil, fmt.Errorf("build upstream request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
|
||||
}
|
||||
|
||||
@@ -420,6 +420,7 @@ type OpenAIGatewayService struct {
|
||||
billingCacheService *BillingCacheService
|
||||
userGroupRateResolver *userGroupRateResolver
|
||||
httpUpstream HTTPUpstream
|
||||
pluginManager *PluginManager
|
||||
deferredService *DeferredService
|
||||
openAITokenProvider *OpenAITokenProvider
|
||||
grokTokenProvider *GrokTokenProvider
|
||||
|
||||
@@ -624,7 +624,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
upstreamStart := time.Now()
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
|
||||
@@ -1787,7 +1787,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
upstreamStart := time.Now()
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
|
||||
@@ -304,7 +304,7 @@ func (s *OpenAIGatewayService) createUpstreamLiveCall(
|
||||
upstreamReq.Header.Set(liveAttestationHeader, attestation)
|
||||
applyLiveUpstreamIdentityHeaders(upstreamReq.Header)
|
||||
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, resolveAccountProxyURL(account), account.ID, account.Concurrency)
|
||||
resp, err := s.doOpenAIUpstream(upstreamReq, resolveAccountProxyURL(account), account)
|
||||
if err != nil {
|
||||
logLiveCreateStageFailure(ctx, account.ID, "upstream_transport", err)
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package service
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (s *OpenAIGatewayService) SetPluginManager(manager *PluginManager) {
|
||||
s.pluginManager = manager
|
||||
}
|
||||
|
||||
// doOpenAIUpstream 只在 OpenAI OAuth 能力绑定已启用时把真实请求交给插件。
|
||||
// 插件返回标准 http.Response,响应解析、错误映射、SSE 和计费仍由现有核心链处理。
|
||||
func (s *OpenAIGatewayService) doOpenAIUpstream(request *http.Request, proxyURL string, account *Account) (*http.Response, error) {
|
||||
if s.pluginManager != nil {
|
||||
response, handled, err := s.pluginManager.RoundTripOpenAIOAuth(request.Context(), request, proxyURL, account)
|
||||
if handled {
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
return s.httpUpstream.Do(request, proxyURL, account.ID, account.Concurrency)
|
||||
}
|
||||
|
||||
// doOpenAIAccountTestUpstream 让 OpenAI OAuth 账号测试与真实转发使用同一插件路径。
|
||||
// API Key 和未命中插件的账号保持各自原有的 HTTPUpstream 行为。
|
||||
func (s *AccountTestService) doOpenAIAccountTestUpstream(
|
||||
request *http.Request,
|
||||
proxyURL string,
|
||||
account *Account,
|
||||
useTLSFallback bool,
|
||||
) (*http.Response, error) {
|
||||
if s.pluginManager != nil {
|
||||
response, handled, err := s.pluginManager.RoundTripOpenAIOAuth(request.Context(), request, proxyURL, account)
|
||||
if handled {
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
if useTLSFallback {
|
||||
return s.httpUpstream.DoWithTLS(
|
||||
request,
|
||||
proxyURL,
|
||||
account.ID,
|
||||
account.Concurrency,
|
||||
s.tlsFPProfileService.ResolveTLSProfile(account),
|
||||
)
|
||||
}
|
||||
return s.httpUpstream.Do(request, proxyURL, account.ID, account.Concurrency)
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Co
|
||||
|
||||
// Client disconnected: do NOT fail over to another account and do NOT evict
|
||||
// this one — the upstream never had a chance to exhibit a fault.
|
||||
if errors.Is(err, context.Canceled) {
|
||||
if errors.Is(err, context.Canceled) || (errors.Is(err, context.DeadlineExceeded) && errors.Is(ctx.Err(), context.DeadlineExceeded)) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -129,6 +129,12 @@ func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Co
|
||||
scheduleOllamaCloudUsageActivity(s.deferredService, account)
|
||||
}
|
||||
|
||||
// 插件已把请求交给上游时,自动切换账号可能造成重复扣费或重复执行。
|
||||
var pluginErr *PluginTransportError
|
||||
if errors.As(err, &pluginErr) && pluginErr.RequestSent {
|
||||
return err
|
||||
}
|
||||
|
||||
if classifyOpenAITransportError(err).Persistent {
|
||||
s.tempUnscheduleOpenAITransportError(ctx, account, safeErr)
|
||||
}
|
||||
|
||||
@@ -112,7 +112,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
}
|
||||
|
||||
wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account)
|
||||
forceHTTPBridge := account.Platform == PlatformGrok
|
||||
forceHTTPBridge := account.Platform == PlatformGrok ||
|
||||
(s.pluginManager != nil && s.pluginManager.ShouldRouteOpenAIOAuth(account))
|
||||
modeRouterV2Enabled := s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.ModeRouterV2Enabled
|
||||
ingressMode := OpenAIWSIngressModeCtxPool
|
||||
if modeRouterV2Enabled && !forceHTTPBridge {
|
||||
|
||||
@@ -407,7 +407,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
if buildErr != nil {
|
||||
return nil, buildErr
|
||||
}
|
||||
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account)
|
||||
if err != nil {
|
||||
if turn == 1 {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
type PluginHostInfo struct {
|
||||
Version string
|
||||
BuildType string
|
||||
}
|
||||
|
||||
func EvaluatePluginCompatibility(manifest PluginManifest, host PluginHostInfo) PluginCompatibility {
|
||||
result := PluginCompatibility{
|
||||
CurrentSub2API: host.Version,
|
||||
RequiredSub2API: manifest.Requires.Sub2API,
|
||||
RecommendedSub2API: manifest.Requires.RecommendedSub2APIVersion,
|
||||
PluginProtocol: manifest.Requires.PluginProtocol,
|
||||
TransportAPI: manifest.Requires.TransportAPI,
|
||||
UIBridge: manifest.Requires.UIBridge,
|
||||
}
|
||||
if manifest.Requires.PluginProtocol != pluginv1.ProtocolVersion ||
|
||||
manifest.Requires.TransportAPI != pluginv1.TransportAPIVersion ||
|
||||
manifest.Requires.UIBridge != pluginv1.UIBridgeVersion {
|
||||
result.Status = "incompatible"
|
||||
result.Message = "插件协议版本与当前 Sub2API 不兼容"
|
||||
return result
|
||||
}
|
||||
if !matchesSemverRange(host.Version, manifest.Requires.Sub2API) {
|
||||
result.Status = "incompatible"
|
||||
result.Message = fmt.Sprintf("当前 Sub2API %s 不满足插件要求 %s", host.Version, manifest.Requires.Sub2API)
|
||||
return result
|
||||
}
|
||||
result.Compatible = true
|
||||
for _, tested := range manifest.Requires.TestedSub2APIVersions {
|
||||
if normalizeSemver(tested) == normalizeSemver(host.Version) {
|
||||
result.Tested = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if result.Tested {
|
||||
result.Status = "compatible"
|
||||
result.Message = "当前 Sub2API 版本已由插件声明测试"
|
||||
} else {
|
||||
result.Status = "untested"
|
||||
result.Message = "版本范围兼容,但插件未声明已测试当前 Sub2API 版本"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeSemver(version string) string {
|
||||
v := strings.TrimSpace(version)
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(v, "v") {
|
||||
v = "v" + v
|
||||
}
|
||||
if !semver.IsValid(v) {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func matchesSemverRange(version, expression string) bool {
|
||||
v := normalizeSemver(version)
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
tokens := strings.Fields(strings.ReplaceAll(expression, ",", " "))
|
||||
if len(tokens) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, token := range tokens {
|
||||
op := "="
|
||||
raw := token
|
||||
for _, candidate := range []string{">=", "<=", ">", "<", "="} {
|
||||
if strings.HasPrefix(token, candidate) {
|
||||
op = candidate
|
||||
raw = strings.TrimSpace(strings.TrimPrefix(token, candidate))
|
||||
break
|
||||
}
|
||||
}
|
||||
bound := normalizeSemver(raw)
|
||||
if bound == "" {
|
||||
return false
|
||||
}
|
||||
comparison := semver.Compare(v, bound)
|
||||
matched := map[string]bool{
|
||||
">=": comparison >= 0,
|
||||
"<=": comparison <= 0,
|
||||
">": comparison > 0,
|
||||
"<": comparison < 0,
|
||||
"=": comparison == 0,
|
||||
}[op]
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEvaluatePluginCompatibility(t *testing.T) {
|
||||
manifest := testPluginManifest(nil)
|
||||
host := PluginHostInfo{Version: "0.1.179", BuildType: "release"}
|
||||
|
||||
result := EvaluatePluginCompatibility(manifest, host)
|
||||
require.True(t, result.Compatible)
|
||||
assert.True(t, result.Tested)
|
||||
assert.Equal(t, "compatible", result.Status)
|
||||
|
||||
manifest.Requires.TestedSub2APIVersions = []string{"0.1.178"}
|
||||
result = EvaluatePluginCompatibility(manifest, host)
|
||||
require.True(t, result.Compatible)
|
||||
assert.False(t, result.Tested)
|
||||
assert.Equal(t, "untested", result.Status)
|
||||
|
||||
manifest.Requires.Sub2API = ">=0.2.0 <0.3.0"
|
||||
result = EvaluatePluginCompatibility(manifest, host)
|
||||
assert.False(t, result.Compatible)
|
||||
assert.Equal(t, "incompatible", result.Status)
|
||||
}
|
||||
|
||||
func TestEvaluatePluginCompatibilityRejectsProtocolMismatch(t *testing.T) {
|
||||
manifest := testPluginManifest(nil)
|
||||
manifest.Requires.PluginProtocol = pluginv1.ProtocolVersion + 1
|
||||
|
||||
result := EvaluatePluginCompatibility(manifest, PluginHostInfo{Version: "0.1.179"})
|
||||
|
||||
assert.False(t, result.Compatible)
|
||||
assert.Equal(t, "incompatible", result.Status)
|
||||
}
|
||||
|
||||
func TestMatchesSemverRange(t *testing.T) {
|
||||
assert.True(t, matchesSemverRange("0.1.179", ">=0.1.170, <0.2.0"))
|
||||
assert.True(t, matchesSemverRange("v1.2.3", "=1.2.3"))
|
||||
assert.False(t, matchesSemverRange("0.1.169", ">=0.1.170 <0.2.0"))
|
||||
assert.False(t, matchesSemverRange("dev", ">=0.1.0"))
|
||||
assert.False(t, matchesSemverRange("0.1.179", "^0.1.0"))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type pluginRoutingHTTPUpstream struct {
|
||||
doCalls int
|
||||
doWithTLSCalls int
|
||||
}
|
||||
|
||||
func (u *pluginRoutingHTTPUpstream) Do(*http.Request, string, int64, int) (*http.Response, error) {
|
||||
u.doCalls++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("legacy")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *pluginRoutingHTTPUpstream) DoWithTLS(
|
||||
request *http.Request,
|
||||
proxyURL string,
|
||||
accountID int64,
|
||||
accountConcurrency int,
|
||||
_ *tlsfingerprint.Profile,
|
||||
) (*http.Response, error) {
|
||||
u.doWithTLSCalls++
|
||||
return u.Do(request, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
func TestPluginManagerRoutingDoesNotTouchAPIKeyOrOtherProviders(t *testing.T) {
|
||||
manager := &PluginManager{}
|
||||
request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
accounts := []*Account{
|
||||
{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
|
||||
{ID: 2, Platform: PlatformAnthropic, Type: AccountTypeOAuth},
|
||||
{ID: 3, Platform: PlatformGemini, Type: AccountTypeOAuth},
|
||||
}
|
||||
for _, account := range accounts {
|
||||
response, handled, routeErr := manager.RoundTripOpenAIOAuth(context.Background(), request, "", account)
|
||||
assert.Nil(t, response)
|
||||
assert.False(t, handled)
|
||||
assert.NoError(t, routeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginManagerRoutingKeepsOAuthOnLegacyPathWithoutEnabledBinding(t *testing.T) {
|
||||
manager := &PluginManager{}
|
||||
request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
account := &Account{ID: 10, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
|
||||
response, handled, routeErr := manager.RoundTripOpenAIOAuth(context.Background(), request, "", account)
|
||||
|
||||
assert.Nil(t, response)
|
||||
assert.False(t, handled)
|
||||
assert.NoError(t, routeErr)
|
||||
}
|
||||
|
||||
func TestPluginManagerRoutingSelectsOnlyEligibleOpenAIOAuthAccounts(t *testing.T) {
|
||||
manager := &PluginManager{}
|
||||
manager.route.Store(&pluginRoute{pluginID: 1, rolloutPercent: 100, unavailable: "测试不可用"})
|
||||
|
||||
assert.True(t, manager.ShouldRouteOpenAIOAuth(&Account{ID: 10, Platform: PlatformOpenAI, Type: AccountTypeOAuth}))
|
||||
assert.False(t, manager.ShouldRouteOpenAIOAuth(&Account{ID: 10, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}))
|
||||
assert.False(t, manager.ShouldRouteOpenAIOAuth(&Account{ID: 10, Platform: PlatformGrok, Type: AccountTypeOAuth}))
|
||||
assert.False(t, manager.ShouldRouteOpenAIOAuth(nil))
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayPluginRoutingPreservesAPIKeyAndFailsClosedForOAuth(t *testing.T) {
|
||||
manager := &PluginManager{}
|
||||
manager.route.Store(&pluginRoute{pluginID: 1, rolloutPercent: 100, unavailable: "测试不可用"})
|
||||
upstream := &pluginRoutingHTTPUpstream{}
|
||||
service := &OpenAIGatewayService{pluginManager: manager, httpUpstream: upstream}
|
||||
|
||||
apiKeyRequest, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
apiKeyResponse, err := service.doOpenAIUpstream(apiKeyRequest, "", &Account{
|
||||
ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, apiKeyResponse)
|
||||
_ = apiKeyResponse.Body.Close()
|
||||
assert.Equal(t, 1, upstream.doCalls)
|
||||
|
||||
oauthRequest, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
oauthResponse, err := service.doOpenAIUpstream(oauthRequest, "", &Account{
|
||||
ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, oauthResponse)
|
||||
assert.Contains(t, err.Error(), "插件不可用")
|
||||
assert.Equal(t, 1, upstream.doCalls)
|
||||
}
|
||||
|
||||
func TestStablePluginBucketIsDeterministicAndBounded(t *testing.T) {
|
||||
for id := int64(1); id <= 1000; id++ {
|
||||
first := stablePluginBucket(id)
|
||||
assert.Equal(t, first, stablePluginBucket(id))
|
||||
assert.Less(t, first, uint64(100))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
PluginCapabilityOpenAIOAuthOutbound = "openai.oauth.outbound_transport.v1"
|
||||
PluginStateDisabled = "disabled"
|
||||
PluginStateStarting = "starting"
|
||||
PluginStateEnabled = "enabled"
|
||||
PluginStateError = "error"
|
||||
PluginStateIncompatible = "incompatible"
|
||||
PluginSignatureTrusted = "trusted"
|
||||
PluginSignatureUnsigned = "unsigned"
|
||||
)
|
||||
|
||||
var pluginIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)+$`)
|
||||
|
||||
var ErrPluginStateChanged = errors.New("插件状态已在其他实例中变化,请刷新后重试")
|
||||
|
||||
// PluginManifest 是 .s2plugin 包中可在执行二进制前检查的声明。
|
||||
type PluginManifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Requires PluginRequirements `json:"requires"`
|
||||
Capabilities []PluginCapability `json:"capabilities"`
|
||||
Runtimes map[string]PluginRuntime `json:"runtimes"`
|
||||
UI PluginUIManifest `json:"ui"`
|
||||
Files map[string]string `json:"files"`
|
||||
}
|
||||
|
||||
type PluginRequirements struct {
|
||||
Sub2API string `json:"sub2api"`
|
||||
RecommendedSub2APIVersion string `json:"recommended_sub2api_version,omitempty"`
|
||||
TestedSub2APIVersions []string `json:"tested_sub2api_versions,omitempty"`
|
||||
PluginProtocol int `json:"plugin_protocol"`
|
||||
TransportAPI int `json:"transport_api"`
|
||||
UIBridge int `json:"ui_bridge"`
|
||||
}
|
||||
|
||||
type PluginCapability struct {
|
||||
ID string `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
AccountType string `json:"account_type"`
|
||||
}
|
||||
|
||||
type PluginRuntime struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type PluginUIManifest struct {
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
}
|
||||
|
||||
type PluginSignature struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
KeyID string `json:"key_id"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// PluginCompatibility 是管理页面展示和启用门禁共同使用的兼容性结论。
|
||||
type PluginCompatibility struct {
|
||||
Compatible bool `json:"compatible"`
|
||||
Tested bool `json:"tested"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
CurrentSub2API string `json:"current_sub2api_version"`
|
||||
RequiredSub2API string `json:"required_sub2api_version"`
|
||||
RecommendedSub2API string `json:"recommended_sub2api_version"`
|
||||
PluginProtocol int `json:"plugin_protocol"`
|
||||
TransportAPI int `json:"transport_api"`
|
||||
UIBridge int `json:"ui_bridge"`
|
||||
}
|
||||
|
||||
type PluginInstallation struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginKey string `json:"plugin_key"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Manifest PluginManifest `json:"manifest"`
|
||||
ArtifactData []byte `json:"-"`
|
||||
ArtifactPath string `json:"-"`
|
||||
InstallPath string `json:"-"`
|
||||
BinaryPath string `json:"-"`
|
||||
BinarySHA256 string `json:"binary_sha256"`
|
||||
SignatureStatus string `json:"signature_status"`
|
||||
State string `json:"state"`
|
||||
ConfigEncrypted string `json:"-"`
|
||||
LastError string `json:"last_error"`
|
||||
InstalledBy *int64 `json:"installed_by"`
|
||||
InstalledAt time.Time `json:"installed_at"`
|
||||
EnabledAt *time.Time `json:"enabled_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Bindings []PluginBinding `json:"bindings"`
|
||||
Compatibility PluginCompatibility `json:"compatibility"`
|
||||
RuntimeHealthy bool `json:"runtime_healthy"`
|
||||
RuntimeMessage string `json:"runtime_message"`
|
||||
}
|
||||
|
||||
type PluginBinding struct {
|
||||
ID int64 `json:"id"`
|
||||
PluginID int64 `json:"plugin_id"`
|
||||
Capability string `json:"capability"`
|
||||
Platform string `json:"platform"`
|
||||
AccountType string `json:"account_type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
RolloutPercent int `json:"rollout_percent"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PluginRepository interface {
|
||||
List(ctx context.Context) ([]*PluginInstallation, error)
|
||||
GetByID(ctx context.Context, id int64) (*PluginInstallation, error)
|
||||
GetByKey(ctx context.Context, key string) (*PluginInstallation, error)
|
||||
Install(ctx context.Context, plugin *PluginInstallation, bindings []PluginBinding) (*PluginInstallation, error)
|
||||
GetArtifact(ctx context.Context, id int64) ([]byte, error)
|
||||
Delete(ctx context.Context, id int64, expectedBinarySHA256 string) error
|
||||
BeginEnable(ctx context.Context, id int64, binarySHA256, expectedState string) error
|
||||
MarkRuntimeHealthy(ctx context.Context, id int64, binarySHA256, configEncrypted string) error
|
||||
UpdateState(ctx context.Context, id int64, state, lastError string, enabledAt *time.Time, expectedBinarySHA256, expectedState string) error
|
||||
UpdateConfig(ctx context.Context, id int64, encrypted, expectedBinarySHA256 string) error
|
||||
UpdateBindingsAndState(ctx context.Context, pluginID int64, bindings []PluginBinding, state, lastError string, enabledAt *time.Time, expectedState, expectedBinarySHA256 string) error
|
||||
}
|
||||
|
||||
func (m PluginManifest) RuntimeKey() string {
|
||||
return runtime.GOOS + "-" + runtime.GOARCH
|
||||
}
|
||||
|
||||
func (m PluginManifest) Validate() error {
|
||||
if m.SchemaVersion != 1 {
|
||||
return fmt.Errorf("不支持的插件清单版本: %d", m.SchemaVersion)
|
||||
}
|
||||
if !pluginIDPattern.MatchString(m.ID) || len(m.ID) > 160 {
|
||||
return errors.New("插件 ID 必须是长度不超过 160 的小写命名空间标识")
|
||||
}
|
||||
if strings.TrimSpace(m.Name) == "" || len(m.Name) > 160 {
|
||||
return errors.New("插件名称不能为空且不能超过 160 个字符")
|
||||
}
|
||||
if normalizeSemver(m.Version) == "" {
|
||||
return errors.New("插件版本必须是有效的语义化版本")
|
||||
}
|
||||
if strings.TrimSpace(m.Requires.Sub2API) == "" {
|
||||
return errors.New("插件必须声明 requires.sub2api")
|
||||
}
|
||||
if m.Requires.PluginProtocol != pluginv1.ProtocolVersion ||
|
||||
m.Requires.TransportAPI != pluginv1.TransportAPIVersion ||
|
||||
m.Requires.UIBridge != pluginv1.UIBridgeVersion {
|
||||
return errors.New("插件协议、传输 API 或 UI Bridge 版本与当前宿主不兼容")
|
||||
}
|
||||
if len(m.Capabilities) == 0 {
|
||||
return errors.New("插件必须声明至少一个能力")
|
||||
}
|
||||
for _, capability := range m.Capabilities {
|
||||
if capability.ID != PluginCapabilityOpenAIOAuthOutbound || capability.Platform != PlatformOpenAI || capability.AccountType != AccountTypeOAuth {
|
||||
return fmt.Errorf("初期仅支持能力 %s", PluginCapabilityOpenAIOAuthOutbound)
|
||||
}
|
||||
}
|
||||
runtimeEntry, ok := m.Runtimes[m.RuntimeKey()]
|
||||
if !ok || !safePluginRelativePath(runtimeEntry.Path) {
|
||||
return fmt.Errorf("插件不支持当前运行平台 %s", m.RuntimeKey())
|
||||
}
|
||||
if !safePluginRelativePath(m.UI.Entrypoint) || !strings.HasPrefix(m.UI.Entrypoint, "ui/") {
|
||||
return errors.New("插件 UI 入口必须位于 ui/ 目录")
|
||||
}
|
||||
if len(m.Files) == 0 {
|
||||
return errors.New("插件清单必须声明文件哈希")
|
||||
}
|
||||
for path, hash := range m.Files {
|
||||
if !safePluginRelativePath(path) || !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(hash) {
|
||||
return fmt.Errorf("插件文件声明无效: %s", path)
|
||||
}
|
||||
}
|
||||
if _, ok := m.Files[runtimeEntry.Path]; !ok {
|
||||
return errors.New("运行时二进制未包含在文件哈希声明中")
|
||||
}
|
||||
if _, ok := m.Files[m.UI.Entrypoint]; !ok {
|
||||
return errors.New("UI 入口未包含在文件哈希声明中")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safePluginRelativePath(path string) bool {
|
||||
cleaned := strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")
|
||||
return cleaned != "" && cleaned != "." && !strings.HasPrefix(cleaned, "/") &&
|
||||
!strings.HasPrefix(cleaned, "../") && !strings.Contains(cleaned, "/../") && cleaned == strings.TrimPrefix(cleaned, "./")
|
||||
}
|
||||
|
||||
func (m PluginManifest) MarshalJSONBytes() ([]byte, error) {
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
func (m PluginManifest) SortedCapabilities() []PluginCapability {
|
||||
out := append([]PluginCapability(nil), m.Capabilities...)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
pluginManifestFilename = "manifest.json"
|
||||
pluginSignatureFilename = "signature.json"
|
||||
pluginArchiveMaxFiles = 512
|
||||
builtInOpenAITransportPluginID = "local.sub2api.openai-transport"
|
||||
builtInOpenAITransportPublisherKeyID = "sub2api-openai-transport-v1"
|
||||
builtInOpenAITransportPublisherKeyBase64 = "MqzSXAoG0iVR5kKWrC+mqcCeExkrT6zAr2WpQ4sA+yc="
|
||||
)
|
||||
|
||||
type PluginPackageInstaller struct {
|
||||
cfg *config.Config
|
||||
hostInfo PluginHostInfo
|
||||
rootDir string
|
||||
}
|
||||
|
||||
func NewPluginPackageInstaller(cfg *config.Config, hostInfo PluginHostInfo) *PluginPackageInstaller {
|
||||
return &PluginPackageInstaller{
|
||||
cfg: cfg,
|
||||
hostInfo: hostInfo,
|
||||
rootDir: resolvePluginRootDir(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
func resolvePluginRootDir(cfg *config.Config) string {
|
||||
if cfg != nil && strings.TrimSpace(cfg.Plugins.DataDir) != "" {
|
||||
return filepath.Clean(cfg.Plugins.DataDir)
|
||||
}
|
||||
base := strings.TrimSpace(os.Getenv("DATA_DIR"))
|
||||
if base == "" {
|
||||
base = "./data"
|
||||
}
|
||||
return filepath.Join(base, "plugins")
|
||||
}
|
||||
|
||||
func (i *PluginPackageInstaller) RootDir() string {
|
||||
return i.rootDir
|
||||
}
|
||||
|
||||
func (i *PluginPackageInstaller) Install(ctx context.Context, reader io.Reader, installedBy *int64) (*PluginInstallation, error) {
|
||||
if i == nil || i.cfg == nil {
|
||||
return nil, errors.New("插件安装器未配置")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stagingDir := filepath.Join(i.rootDir, "staging")
|
||||
packagesDir := filepath.Join(i.rootDir, "packages")
|
||||
installedDir := filepath.Join(i.rootDir, "installed")
|
||||
for _, dir := range []string{stagingDir, packagesDir, installedDir} {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("创建插件目录: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp(stagingDir, "upload-*.s2plugin")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建插件上传临时文件: %w", err)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
committed := false
|
||||
defer func() {
|
||||
_ = tempFile.Close()
|
||||
if !committed {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
hasher := sha256.New()
|
||||
limit := i.cfg.Plugins.MaxUploadBytes
|
||||
written, err := io.Copy(io.MultiWriter(tempFile, hasher), io.LimitReader(reader, limit+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取插件包: %w", err)
|
||||
}
|
||||
if written > limit {
|
||||
return nil, fmt.Errorf("插件包超过 %d 字节限制", limit)
|
||||
}
|
||||
if err := tempFile.Sync(); err != nil {
|
||||
return nil, fmt.Errorf("同步插件包: %w", err)
|
||||
}
|
||||
if err := tempFile.Close(); err != nil {
|
||||
return nil, fmt.Errorf("关闭插件包: %w", err)
|
||||
}
|
||||
artifactSHA := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
archive, err := zip.OpenReader(tempPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("插件包不是有效的 ZIP: %w", err)
|
||||
}
|
||||
defer func() { _ = archive.Close() }()
|
||||
manifest, _, signatureStatus, err := i.inspectArchive(&archive.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
compatibility := EvaluatePluginCompatibility(manifest, i.hostInfo)
|
||||
initialState := PluginStateDisabled
|
||||
if !compatibility.Compatible {
|
||||
initialState = PluginStateIncompatible
|
||||
}
|
||||
|
||||
installParent := filepath.Join(installedDir, manifest.ID)
|
||||
if err := os.MkdirAll(installParent, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("创建插件安装父目录: %w", err)
|
||||
}
|
||||
extractPath, err := os.MkdirTemp(installParent, ".install-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建插件安装临时目录: %w", err)
|
||||
}
|
||||
installNonce := strings.TrimPrefix(filepath.Base(extractPath), ".install-")
|
||||
installPath := filepath.Join(installParent, manifest.Version+"-"+artifactSHA[:12]+"-"+installNonce)
|
||||
extracted := false
|
||||
defer func() {
|
||||
if !extracted {
|
||||
_ = os.RemoveAll(extractPath)
|
||||
}
|
||||
}()
|
||||
if err := i.extractArchive(ctx, &archive.Reader, manifest, extractPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Rename(extractPath, installPath); err != nil {
|
||||
return nil, fmt.Errorf("提交插件安装目录: %w", err)
|
||||
}
|
||||
extracted = true
|
||||
|
||||
artifactPath := filepath.Join(packagesDir, manifest.ID+"-"+manifest.Version+"-"+artifactSHA[:12]+"-"+installNonce+".s2plugin")
|
||||
if err := os.Rename(tempPath, artifactPath); err != nil {
|
||||
_ = os.RemoveAll(installPath)
|
||||
return nil, fmt.Errorf("保存插件包: %w", err)
|
||||
}
|
||||
committed = true
|
||||
artifactData, err := os.ReadFile(artifactPath)
|
||||
if err != nil {
|
||||
_ = os.Remove(artifactPath)
|
||||
_ = os.RemoveAll(installPath)
|
||||
return nil, fmt.Errorf("读取已保存插件包: %w", err)
|
||||
}
|
||||
runtimeEntry := manifest.Runtimes[manifest.RuntimeKey()]
|
||||
return &PluginInstallation{
|
||||
PluginKey: manifest.ID,
|
||||
Name: manifest.Name,
|
||||
Version: manifest.Version,
|
||||
Description: manifest.Description,
|
||||
Author: manifest.Author,
|
||||
Manifest: manifest,
|
||||
ArtifactData: artifactData,
|
||||
ArtifactPath: artifactPath,
|
||||
InstallPath: installPath,
|
||||
BinaryPath: filepath.Join(installPath, filepath.FromSlash(runtimeEntry.Path)),
|
||||
BinarySHA256: manifest.Files[runtimeEntry.Path],
|
||||
SignatureStatus: signatureStatus,
|
||||
State: initialState,
|
||||
InstalledBy: installedBy,
|
||||
Compatibility: compatibility,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (i *PluginPackageInstaller) inspectArchive(archive *zip.Reader) (PluginManifest, []byte, string, error) {
|
||||
if len(archive.File) == 0 || len(archive.File) > pluginArchiveMaxFiles {
|
||||
return PluginManifest{}, nil, "", errors.New("插件包文件数量无效")
|
||||
}
|
||||
entries := make(map[string]*zip.File, len(archive.File))
|
||||
var total uint64
|
||||
for _, file := range archive.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
if _, err := normalizePluginArchivePath(strings.TrimSuffix(file.Name, "/")); err != nil {
|
||||
return PluginManifest{}, nil, "", err
|
||||
}
|
||||
continue
|
||||
}
|
||||
name, err := normalizePluginArchivePath(file.Name)
|
||||
if err != nil {
|
||||
return PluginManifest{}, nil, "", err
|
||||
}
|
||||
if _, exists := entries[name]; exists {
|
||||
return PluginManifest{}, nil, "", fmt.Errorf("插件包包含重复路径: %s", name)
|
||||
}
|
||||
if file.Mode()&os.ModeSymlink != 0 {
|
||||
return PluginManifest{}, nil, "", fmt.Errorf("插件包不允许符号链接: %s", name)
|
||||
}
|
||||
total += file.UncompressedSize64
|
||||
if total > uint64(i.cfg.Plugins.MaxUncompressedBytes) {
|
||||
return PluginManifest{}, nil, "", errors.New("插件包解压后体积超过限制")
|
||||
}
|
||||
entries[name] = file
|
||||
}
|
||||
manifestFile := entries[pluginManifestFilename]
|
||||
if manifestFile == nil {
|
||||
return PluginManifest{}, nil, "", errors.New("插件包缺少 manifest.json")
|
||||
}
|
||||
manifestRaw, err := readPluginZipFile(manifestFile, 2*1024*1024)
|
||||
if err != nil {
|
||||
return PluginManifest{}, nil, "", fmt.Errorf("读取插件清单: %w", err)
|
||||
}
|
||||
var manifest PluginManifest
|
||||
decoder := json.NewDecoder(bytes.NewReader(manifestRaw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&manifest); err != nil {
|
||||
return PluginManifest{}, nil, "", fmt.Errorf("解析插件清单: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return PluginManifest{}, nil, "", errors.New("插件清单只能包含一个 JSON 对象")
|
||||
}
|
||||
if err := manifest.Validate(); err != nil {
|
||||
return PluginManifest{}, nil, "", err
|
||||
}
|
||||
for path := range entries {
|
||||
if path == pluginManifestFilename || path == pluginSignatureFilename {
|
||||
continue
|
||||
}
|
||||
if _, declared := manifest.Files[path]; !declared {
|
||||
return PluginManifest{}, nil, "", fmt.Errorf("插件包包含未声明文件: %s", path)
|
||||
}
|
||||
}
|
||||
for path := range manifest.Files {
|
||||
if entries[path] == nil {
|
||||
return PluginManifest{}, nil, "", fmt.Errorf("插件包缺少已声明文件: %s", path)
|
||||
}
|
||||
}
|
||||
signatureStatus, err := i.verifySignature(entries[pluginSignatureFilename], manifestRaw, manifest.ID)
|
||||
if err != nil {
|
||||
return PluginManifest{}, nil, "", err
|
||||
}
|
||||
return manifest, manifestRaw, signatureStatus, nil
|
||||
}
|
||||
|
||||
func (i *PluginPackageInstaller) verifySignature(file *zip.File, manifestRaw []byte, pluginID string) (string, error) {
|
||||
if file == nil {
|
||||
if i.cfg.Plugins.AllowUnsigned {
|
||||
return PluginSignatureUnsigned, nil
|
||||
}
|
||||
return "", errors.New("生产配置不允许安装未签名插件")
|
||||
}
|
||||
raw, err := readPluginZipFile(file, 64*1024)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取插件签名: %w", err)
|
||||
}
|
||||
var signature PluginSignature
|
||||
if err := json.Unmarshal(raw, &signature); err != nil {
|
||||
return "", fmt.Errorf("解析插件签名: %w", err)
|
||||
}
|
||||
if signature.Algorithm != "ed25519" || strings.TrimSpace(signature.KeyID) == "" {
|
||||
return "", errors.New("插件签名算法或密钥 ID 无效")
|
||||
}
|
||||
encodedKey := trustedPluginPublisherKey(i.cfg, signature.KeyID, pluginID)
|
||||
if encodedKey == "" {
|
||||
return "", fmt.Errorf("插件发布者密钥不受信任: %s", signature.KeyID)
|
||||
}
|
||||
publicKey, err := base64.StdEncoding.DecodeString(encodedKey)
|
||||
if err != nil || len(publicKey) != ed25519.PublicKeySize {
|
||||
return "", fmt.Errorf("受信任发布者密钥无效: %s", signature.KeyID)
|
||||
}
|
||||
signatureBytes, err := base64.StdEncoding.DecodeString(signature.Signature)
|
||||
if err != nil || !ed25519.Verify(ed25519.PublicKey(publicKey), manifestRaw, signatureBytes) {
|
||||
return "", errors.New("插件签名校验失败")
|
||||
}
|
||||
return PluginSignatureTrusted, nil
|
||||
}
|
||||
|
||||
func trustedPluginPublisherKey(cfg *config.Config, keyID, pluginID string) string {
|
||||
// 内置公钥是官方私有插件的固定信任根,不允许被部署配置覆盖。
|
||||
if keyID == builtInOpenAITransportPublisherKeyID {
|
||||
if pluginID != builtInOpenAITransportPluginID {
|
||||
return ""
|
||||
}
|
||||
return builtInOpenAITransportPublisherKeyBase64
|
||||
}
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cfg.Plugins.TrustedPublishers[keyID])
|
||||
}
|
||||
|
||||
func (i *PluginPackageInstaller) extractArchive(ctx context.Context, archive *zip.Reader, manifest PluginManifest, target string) error {
|
||||
var extractedBytes int64
|
||||
extractLimit := i.cfg.Plugins.MaxUncompressedBytes
|
||||
for path, expectedHash := range manifest.Files {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
var source *zip.File
|
||||
for _, file := range archive.File {
|
||||
if strings.ReplaceAll(file.Name, "\\", "/") == path {
|
||||
source = file
|
||||
break
|
||||
}
|
||||
}
|
||||
if source == nil {
|
||||
return fmt.Errorf("缺少插件文件: %s", path)
|
||||
}
|
||||
destination, err := safePluginJoin(target, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil {
|
||||
return fmt.Errorf("创建插件文件目录: %w", err)
|
||||
}
|
||||
input, err := source.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开插件文件 %s: %w", path, err)
|
||||
}
|
||||
hasher := sha256.New()
|
||||
mode := os.FileMode(0o600)
|
||||
if path == manifest.Runtimes[manifest.RuntimeKey()].Path {
|
||||
mode = 0o700
|
||||
}
|
||||
output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
|
||||
if err != nil {
|
||||
_ = input.Close()
|
||||
return fmt.Errorf("创建插件文件 %s: %w", path, err)
|
||||
}
|
||||
remaining := extractLimit - extractedBytes
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
copied, copyErr := io.Copy(io.MultiWriter(output, hasher), io.LimitReader(input, remaining+1))
|
||||
extractedBytes += copied
|
||||
closeOutErr := output.Close()
|
||||
closeInErr := input.Close()
|
||||
if copyErr != nil || closeOutErr != nil || closeInErr != nil {
|
||||
return fmt.Errorf("解压插件文件 %s 失败", path)
|
||||
}
|
||||
if extractedBytes > extractLimit {
|
||||
return errors.New("插件包实际解压体积超过限制")
|
||||
}
|
||||
if actual := hex.EncodeToString(hasher.Sum(nil)); actual != expectedHash {
|
||||
return fmt.Errorf("插件文件哈希不匹配: %s", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizePluginArchivePath(name string) (string, error) {
|
||||
normalized := strings.ReplaceAll(strings.TrimSpace(name), "\\", "/")
|
||||
if normalized == "" || strings.HasPrefix(normalized, "/") || strings.Contains(normalized, "\x00") {
|
||||
return "", errors.New("插件包包含无效路径")
|
||||
}
|
||||
cleaned := filepath.ToSlash(filepath.Clean(normalized))
|
||||
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || cleaned != normalized {
|
||||
return "", fmt.Errorf("插件包包含不安全路径: %s", name)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func safePluginJoin(root, relative string) (string, error) {
|
||||
destination := filepath.Join(root, filepath.FromSlash(relative))
|
||||
rel, err := filepath.Rel(root, destination)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("插件路径越界: %s", relative)
|
||||
}
|
||||
return destination, nil
|
||||
}
|
||||
|
||||
func readPluginZipFile(file *zip.File, limit int64) ([]byte, error) {
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
data, err := io.ReadAll(io.LimitReader(reader, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, errors.New("插件文件超过读取限制")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPluginPackageInstallerInstallUnsignedDevelopmentPackage(t *testing.T) {
|
||||
cfg := testPluginConfig(t.TempDir(), true)
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179", BuildType: "release"})
|
||||
archive := buildTestPluginArchive(t, nil, "")
|
||||
|
||||
installation, err := installer.Install(context.Background(), bytes.NewReader(archive), nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, PluginStateDisabled, installation.State)
|
||||
assert.Equal(t, PluginSignatureUnsigned, installation.SignatureStatus)
|
||||
assert.FileExists(t, installation.ArtifactPath)
|
||||
info, statErr := os.Stat(installation.BinaryPath)
|
||||
require.NoError(t, statErr)
|
||||
assert.NotZero(t, info.Mode()&0o100)
|
||||
assert.Contains(t, installation.InstallPath, filepath.Join("installed", "com.example.openai-transport"))
|
||||
}
|
||||
|
||||
func TestPluginPackageInstallerAllowsRepeatedIdenticalUpload(t *testing.T) {
|
||||
cfg := testPluginConfig(t.TempDir(), true)
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
|
||||
archive := buildTestPluginArchive(t, nil, "")
|
||||
|
||||
first, err := installer.Install(context.Background(), bytes.NewReader(archive), nil)
|
||||
require.NoError(t, err)
|
||||
second, err := installer.Install(context.Background(), bytes.NewReader(archive), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, first.InstallPath, second.InstallPath)
|
||||
assert.NotEqual(t, first.ArtifactPath, second.ArtifactPath)
|
||||
assert.FileExists(t, first.BinaryPath)
|
||||
assert.FileExists(t, second.BinaryPath)
|
||||
}
|
||||
|
||||
func TestPluginPackageInstallerRejectsUnsignedPackageByDefault(t *testing.T) {
|
||||
cfg := testPluginConfig(t.TempDir(), false)
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
|
||||
|
||||
_, err := installer.Install(context.Background(), bytes.NewReader(buildTestPluginArchive(t, nil, "")), nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "不允许安装未签名插件")
|
||||
}
|
||||
|
||||
func TestPluginPackageInstallerVerifiesTrustedSignature(t *testing.T) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
cfg := testPluginConfig(t.TempDir(), false)
|
||||
cfg.Plugins.TrustedPublishers["local-test"] = base64.StdEncoding.EncodeToString(publicKey)
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
|
||||
|
||||
installation, installErr := installer.Install(
|
||||
context.Background(),
|
||||
bytes.NewReader(buildTestPluginArchive(t, privateKey, "local-test")),
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, installErr)
|
||||
assert.Equal(t, PluginSignatureTrusted, installation.SignatureStatus)
|
||||
}
|
||||
|
||||
func TestBuiltInOpenAITransportPublisherDoesNotRequireConfiguration(t *testing.T) {
|
||||
cfg := testPluginConfig(t.TempDir(), false)
|
||||
cfg.Plugins.TrustedPublishers[builtInOpenAITransportPublisherKeyID] = "不能覆盖内置公钥"
|
||||
|
||||
encodedKey := trustedPluginPublisherKey(cfg, builtInOpenAITransportPublisherKeyID, builtInOpenAITransportPluginID)
|
||||
publicKey, err := base64.StdEncoding.DecodeString(encodedKey)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, publicKey, ed25519.PublicKeySize)
|
||||
assert.Equal(t, builtInOpenAITransportPublisherKeyBase64, encodedKey)
|
||||
assert.Empty(t, trustedPluginPublisherKey(cfg, builtInOpenAITransportPublisherKeyID, "com.example.other-plugin"))
|
||||
}
|
||||
|
||||
func TestPluginPackageInstallerRejectsPathTraversal(t *testing.T) {
|
||||
cfg := testPluginConfig(t.TempDir(), true)
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
|
||||
archive := buildTestPluginArchiveWithExtra(t, nil, "", "../escape", []byte("escape"))
|
||||
|
||||
_, err := installer.Install(context.Background(), bytes.NewReader(archive), nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "不安全路径")
|
||||
}
|
||||
|
||||
func TestPluginPackageInstallerKeepsHostVersionMismatchDisabled(t *testing.T) {
|
||||
cfg := testPluginConfig(t.TempDir(), true)
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.2.0"})
|
||||
|
||||
installation, err := installer.Install(context.Background(), bytes.NewReader(buildTestPluginArchive(t, nil, "")), nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, PluginStateIncompatible, installation.State)
|
||||
assert.False(t, installation.Compatibility.Compatible)
|
||||
}
|
||||
|
||||
func TestPluginPackageInstallerRejectsHashMismatch(t *testing.T) {
|
||||
cfg := testPluginConfig(t.TempDir(), true)
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
|
||||
manifest := testPluginManifest(map[string][]byte{
|
||||
"bin/plugin": []byte("binary"),
|
||||
"ui/index.html": []byte("<html></html>"),
|
||||
})
|
||||
manifest.Files["bin/plugin"] = string(bytes.Repeat([]byte("0"), 64))
|
||||
|
||||
_, err := installer.Install(context.Background(), bytes.NewReader(buildPluginArchive(t, manifest, nil, "", nil)), nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "哈希不匹配")
|
||||
}
|
||||
|
||||
func TestPluginPackageInstallerEnforcesActualExtractionLimit(t *testing.T) {
|
||||
archiveData := buildTestPluginArchive(t, nil, "")
|
||||
archive, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData)))
|
||||
require.NoError(t, err)
|
||||
cfg := testPluginConfig(t.TempDir(), true)
|
||||
cfg.Plugins.MaxUncompressedBytes = 8
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
|
||||
|
||||
err = installer.extractArchive(context.Background(), archive, testPluginManifest(nil), t.TempDir())
|
||||
|
||||
require.ErrorContains(t, err, "实际解压体积超过限制")
|
||||
}
|
||||
|
||||
func testPluginConfig(root string, allowUnsigned bool) *config.Config {
|
||||
return &config.Config{Plugins: config.PluginConfig{
|
||||
DataDir: root,
|
||||
AllowUnsigned: allowUnsigned,
|
||||
TrustedPublishers: map[string]string{},
|
||||
MaxUploadBytes: 64 * 1024 * 1024,
|
||||
MaxUncompressedBytes: 128 * 1024 * 1024,
|
||||
StartTimeoutSeconds: 5,
|
||||
}}
|
||||
}
|
||||
|
||||
func testPluginManifest(files map[string][]byte) PluginManifest {
|
||||
if files == nil {
|
||||
files = map[string][]byte{
|
||||
"bin/plugin": []byte("binary"),
|
||||
"ui/index.html": []byte("<html></html>"),
|
||||
}
|
||||
}
|
||||
hashes := make(map[string]string, len(files))
|
||||
for path, data := range files {
|
||||
digest := sha256.Sum256(data)
|
||||
hashes[path] = hex.EncodeToString(digest[:])
|
||||
}
|
||||
return PluginManifest{
|
||||
SchemaVersion: 1,
|
||||
ID: "com.example.openai-transport",
|
||||
Name: "测试 OpenAI Transport",
|
||||
Version: "0.1.0",
|
||||
Requires: PluginRequirements{
|
||||
Sub2API: ">=0.1.170 <0.2.0",
|
||||
RecommendedSub2APIVersion: "0.1.179",
|
||||
TestedSub2APIVersions: []string{"0.1.179"},
|
||||
PluginProtocol: pluginv1.ProtocolVersion,
|
||||
TransportAPI: pluginv1.TransportAPIVersion,
|
||||
UIBridge: pluginv1.UIBridgeVersion,
|
||||
},
|
||||
Capabilities: []PluginCapability{{
|
||||
ID: PluginCapabilityOpenAIOAuthOutbound,
|
||||
Platform: PlatformOpenAI,
|
||||
AccountType: AccountTypeOAuth,
|
||||
}},
|
||||
Runtimes: map[string]PluginRuntime{
|
||||
PluginManifest{}.RuntimeKey(): {Path: "bin/plugin"},
|
||||
},
|
||||
UI: PluginUIManifest{Entrypoint: "ui/index.html"},
|
||||
Files: hashes,
|
||||
}
|
||||
}
|
||||
|
||||
func buildTestPluginArchive(t *testing.T, privateKey ed25519.PrivateKey, keyID string) []byte {
|
||||
return buildPluginArchive(t, testPluginManifest(nil), privateKey, keyID, nil)
|
||||
}
|
||||
|
||||
func buildTestPluginArchiveWithExtra(t *testing.T, privateKey ed25519.PrivateKey, keyID, path string, data []byte) []byte {
|
||||
return buildPluginArchive(t, testPluginManifest(nil), privateKey, keyID, map[string][]byte{path: data})
|
||||
}
|
||||
|
||||
func buildPluginArchive(
|
||||
t *testing.T,
|
||||
manifest PluginManifest,
|
||||
privateKey ed25519.PrivateKey,
|
||||
keyID string,
|
||||
extra map[string][]byte,
|
||||
) []byte {
|
||||
t.Helper()
|
||||
manifestRaw, err := json.Marshal(manifest)
|
||||
require.NoError(t, err)
|
||||
var buffer bytes.Buffer
|
||||
writer := zip.NewWriter(&buffer)
|
||||
writeZipEntry(t, writer, pluginManifestFilename, manifestRaw)
|
||||
for path := range manifest.Files {
|
||||
data := []byte("binary")
|
||||
if path == "ui/index.html" {
|
||||
data = []byte("<html></html>")
|
||||
}
|
||||
writeZipEntry(t, writer, path, data)
|
||||
}
|
||||
for path, data := range extra {
|
||||
writeZipEntry(t, writer, path, data)
|
||||
}
|
||||
if len(privateKey) > 0 {
|
||||
signatureRaw, marshalErr := json.Marshal(PluginSignature{
|
||||
Algorithm: "ed25519",
|
||||
KeyID: keyID,
|
||||
Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestRaw)),
|
||||
})
|
||||
require.NoError(t, marshalErr)
|
||||
writeZipEntry(t, writer, pluginSignatureFilename, signatureRaw)
|
||||
}
|
||||
require.NoError(t, writer.Close())
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func writeZipEntry(t *testing.T, writer *zip.Writer, path string, data []byte) {
|
||||
t.Helper()
|
||||
entry, err := writer.Create(path)
|
||||
require.NoError(t, err)
|
||||
_, err = entry.Write(data)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||||
hclog "github.com/hashicorp/go-hclog"
|
||||
hcplugin "github.com/hashicorp/go-plugin"
|
||||
)
|
||||
|
||||
type pluginRuntime struct {
|
||||
installation *PluginInstallation
|
||||
client *hcplugin.Client
|
||||
api pluginv1.TransportPluginClient
|
||||
inFlight atomic.Int64
|
||||
draining atomic.Bool
|
||||
done chan struct{}
|
||||
doneOnce sync.Once
|
||||
}
|
||||
|
||||
func startPluginRuntime(ctx context.Context, installation *PluginInstallation, startTimeout time.Duration, socketDir string) (*pluginRuntime, error) {
|
||||
if installation == nil {
|
||||
return nil, errors.New("插件安装记录为空")
|
||||
}
|
||||
checksum, err := hex.DecodeString(installation.BinarySHA256)
|
||||
if err != nil || len(checksum) != sha256.Size {
|
||||
return nil, errors.New("插件二进制哈希无效")
|
||||
}
|
||||
cmd := exec.CommandContext(context.WithoutCancel(ctx), installation.BinaryPath)
|
||||
client := hcplugin.NewClient(&hcplugin.ClientConfig{
|
||||
HandshakeConfig: pluginv1.HandshakeConfig,
|
||||
Plugins: pluginv1.ClientPluginMap(),
|
||||
Cmd: cmd,
|
||||
AllowedProtocols: []hcplugin.Protocol{hcplugin.ProtocolGRPC},
|
||||
StartTimeout: startTimeout,
|
||||
SecureConfig: &hcplugin.SecureConfig{
|
||||
Checksum: checksum,
|
||||
Hash: sha256.New(),
|
||||
},
|
||||
Logger: hclog.NewNullLogger(),
|
||||
SyncStdout: io.Discard,
|
||||
SyncStderr: io.Discard,
|
||||
UnixSocketConfig: &hcplugin.UnixSocketConfig{TempDir: socketDir},
|
||||
SkipHostEnv: true,
|
||||
})
|
||||
rpcClient, err := client.Client()
|
||||
if err != nil {
|
||||
client.Kill()
|
||||
return nil, fmt.Errorf("启动插件进程: %w", err)
|
||||
}
|
||||
dispensed, err := rpcClient.Dispense(pluginv1.TransportPluginName)
|
||||
if err != nil {
|
||||
client.Kill()
|
||||
return nil, fmt.Errorf("获取插件传输能力: %w", err)
|
||||
}
|
||||
api, ok := dispensed.(pluginv1.TransportPluginClient)
|
||||
if !ok {
|
||||
client.Kill()
|
||||
return nil, errors.New("插件未实现传输 gRPC 客户端")
|
||||
}
|
||||
runtime := &pluginRuntime{
|
||||
installation: installation,
|
||||
client: client,
|
||||
api: api,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
infoCtx, cancel := context.WithTimeout(ctx, startTimeout)
|
||||
defer cancel()
|
||||
info, err := api.GetInfo(infoCtx, &pluginv1.GetInfoRequest{})
|
||||
if err != nil {
|
||||
runtime.kill()
|
||||
return nil, fmt.Errorf("读取插件信息: %w", err)
|
||||
}
|
||||
if info.PluginId != installation.PluginKey || info.PluginVersion != installation.Version ||
|
||||
info.ProtocolVersion != pluginv1.ProtocolVersion || info.TransportApiVersion != pluginv1.TransportAPIVersion {
|
||||
runtime.kill()
|
||||
return nil, errors.New("插件运行时信息与已校验清单不一致")
|
||||
}
|
||||
health, err := api.Health(infoCtx, &pluginv1.HealthRequest{})
|
||||
if err != nil || !health.Healthy {
|
||||
runtime.kill()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("插件健康检查失败: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("插件不健康: %s", health.Message)
|
||||
}
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) validateAndApplyConfig(ctx context.Context, configJSON []byte) error {
|
||||
_, err := r.validateAndApplyNormalizedConfig(ctx, configJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) validateAndApplyNormalizedConfig(ctx context.Context, configJSON []byte) ([]byte, error) {
|
||||
validation, err := r.api.ValidateConfig(ctx, &pluginv1.ValidateConfigRequest{ConfigJson: configJSON})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("插件配置校验失败: %w", err)
|
||||
}
|
||||
if !validation.Valid {
|
||||
return nil, fmt.Errorf("插件配置无效: %s", validation.Message)
|
||||
}
|
||||
if len(validation.NormalizedConfigJson) > 0 {
|
||||
configJSON = validation.NormalizedConfigJson
|
||||
}
|
||||
if len(configJSON) == 0 || len(configJSON) > pluginConfigMaxBytes || !json.Valid(configJSON) {
|
||||
return nil, errors.New("插件返回的规范化配置不是有效且大小受限的 JSON")
|
||||
}
|
||||
var normalized any
|
||||
if err := json.Unmarshal(configJSON, &normalized); err != nil {
|
||||
return nil, fmt.Errorf("解析插件规范化配置: %w", err)
|
||||
}
|
||||
if normalized == nil {
|
||||
return nil, errors.New("插件返回的规范化配置根节点必须是对象")
|
||||
}
|
||||
if _, ok := normalized.(map[string]any); !ok {
|
||||
return nil, errors.New("插件返回的规范化配置根节点必须是对象")
|
||||
}
|
||||
configJSON, err = json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化插件规范化配置: %w", err)
|
||||
}
|
||||
applied, err := r.api.ApplyConfig(ctx, &pluginv1.ApplyConfigRequest{ConfigJson: configJSON})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("应用插件配置失败: %w", err)
|
||||
}
|
||||
if !applied.Applied {
|
||||
return nil, fmt.Errorf("插件拒绝应用配置: %s", applied.Message)
|
||||
}
|
||||
return configJSON, nil
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) checkHealth(ctx context.Context) error {
|
||||
if r == nil || r.api == nil || r.client == nil || r.client.Exited() {
|
||||
return errors.New("插件进程已退出")
|
||||
}
|
||||
health, err := r.api.Health(ctx, &pluginv1.HealthRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("插件健康检查失败: %w", err)
|
||||
}
|
||||
if health == nil || !health.Healthy {
|
||||
message := "插件报告不健康"
|
||||
if health != nil && strings.TrimSpace(health.Message) != "" {
|
||||
message = "插件不健康: " + health.Message
|
||||
}
|
||||
return errors.New(message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) beginRequest() bool {
|
||||
if r == nil || r.draining.Load() {
|
||||
return false
|
||||
}
|
||||
r.inFlight.Add(1)
|
||||
if r.draining.Load() {
|
||||
r.finishRequest()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) finishRequest() {
|
||||
if r.inFlight.Add(-1) == 0 && r.draining.Load() {
|
||||
r.doneOnce.Do(func() { close(r.done) })
|
||||
}
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) drain(timeout time.Duration) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.draining.Store(true)
|
||||
if r.inFlight.Load() == 0 {
|
||||
r.doneOnce.Do(func() { close(r.done) })
|
||||
}
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-r.done:
|
||||
case <-timer.C:
|
||||
}
|
||||
r.kill()
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) kill() {
|
||||
if r != nil && r.client != nil {
|
||||
r.client.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) roundTrip(ctx context.Context, request *http.Request, proxyURL string, account *Account) (*http.Response, error) {
|
||||
if request == nil || request.URL == nil || account == nil {
|
||||
return nil, errors.New("插件出站请求参数不完整")
|
||||
}
|
||||
streamCtx, cancel := context.WithCancel(ctx)
|
||||
stream, err := r.api.Forward(streamCtx)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, normalizePluginRPCError(ctx, "创建插件转发流", err, false)
|
||||
}
|
||||
requestID := strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.FormatInt(account.ID, 36)
|
||||
if err := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_Start{Start: &pluginv1.ForwardRequestStart{
|
||||
RequestId: requestID,
|
||||
Method: request.Method,
|
||||
Url: request.URL.String(),
|
||||
Host: request.Host,
|
||||
Headers: headersToPlugin(request.Header),
|
||||
ProxyUrl: proxyURL,
|
||||
AccountId: account.ID,
|
||||
AccountConcurrency: int32(account.Concurrency),
|
||||
Platform: account.Platform,
|
||||
AccountType: account.Type,
|
||||
ContentLength: request.ContentLength,
|
||||
HasBody: request.Body != nil && request.Body != http.NoBody,
|
||||
}}}); err != nil {
|
||||
cancel()
|
||||
// gRPC Send 返回错误时无法证明服务端没有收到元数据,必须禁止自动重放。
|
||||
return nil, normalizePluginRPCError(ctx, "发送插件请求元数据", err, true)
|
||||
}
|
||||
sendErr := make(chan error, 1)
|
||||
go func() {
|
||||
err := sendPluginRequestBody(stream, request.Body)
|
||||
sendErr <- err
|
||||
if err != nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
first, err := stream.Recv()
|
||||
if err != nil {
|
||||
cancel()
|
||||
select {
|
||||
case bodyErr := <-sendErr:
|
||||
if bodyErr != nil {
|
||||
return nil, normalizePluginRPCError(ctx, "发送插件请求体", bodyErr, true)
|
||||
}
|
||||
default:
|
||||
}
|
||||
return nil, normalizePluginRPCError(ctx, "接收插件响应头", err, true)
|
||||
}
|
||||
if frameError := first.GetError(); frameError != nil {
|
||||
cancel()
|
||||
return nil, &PluginTransportError{Code: frameError.Code, Message: frameError.Message, RequestSent: frameError.RequestSent}
|
||||
}
|
||||
start := first.GetStart()
|
||||
if start == nil || start.StatusCode < 100 || start.StatusCode > 599 {
|
||||
cancel()
|
||||
return nil, &PluginTransportError{
|
||||
Code: "PLUGIN_INVALID_RESPONSE",
|
||||
Message: "插件未返回有效的 HTTP 响应头",
|
||||
RequestSent: true,
|
||||
}
|
||||
}
|
||||
pipeReader, pipeWriter := io.Pipe()
|
||||
body := &pluginResponseBody{
|
||||
reader: pipeReader,
|
||||
cancel: cancel,
|
||||
done: r.finishRequest,
|
||||
}
|
||||
go receivePluginResponseBody(stream, pipeWriter, sendErr)
|
||||
return &http.Response{
|
||||
Status: start.Status,
|
||||
StatusCode: int(start.StatusCode),
|
||||
Proto: start.Protocol,
|
||||
ProtoMajor: int(start.ProtocolMajor),
|
||||
ProtoMinor: int(start.ProtocolMinor),
|
||||
Header: headersFromPlugin(start.Headers),
|
||||
Body: body,
|
||||
ContentLength: start.ContentLength,
|
||||
Request: request,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type PluginTransportError struct {
|
||||
Code string
|
||||
Message string
|
||||
RequestSent bool
|
||||
}
|
||||
|
||||
func (e *PluginTransportError) Error() string {
|
||||
if e == nil {
|
||||
return "插件传输失败"
|
||||
}
|
||||
code := strings.Map(func(value rune) rune {
|
||||
if value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' || value == '_' || value == '-' || value == '.' {
|
||||
return value
|
||||
}
|
||||
return -1
|
||||
}, e.Code)
|
||||
if len(code) > 64 {
|
||||
code = code[:64]
|
||||
}
|
||||
return fmt.Sprintf("插件传输失败 [%s]: %s", code, sanitizeUpstreamErrorMessage(e.Message))
|
||||
}
|
||||
|
||||
func normalizePluginRPCError(ctx context.Context, operation string, err error, requestMayHaveBeenSent bool) error {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return &PluginTransportError{
|
||||
Code: "PLUGIN_RPC_ERROR",
|
||||
Message: fmt.Sprintf("%s: %v", operation, err),
|
||||
RequestSent: requestMayHaveBeenSent,
|
||||
}
|
||||
}
|
||||
|
||||
func sendPluginRequestBody(stream pluginv1.TransportPlugin_ForwardClient, body io.ReadCloser) error {
|
||||
if body != nil {
|
||||
defer func() { _ = body.Close() }()
|
||||
buffer := make([]byte, 32*1024)
|
||||
for {
|
||||
read, err := body.Read(buffer)
|
||||
if read > 0 {
|
||||
chunk := append([]byte(nil), buffer[:read]...)
|
||||
if sendErr := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_BodyChunk{BodyChunk: chunk}}); sendErr != nil {
|
||||
return sendErr
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_BodyEnd{BodyEnd: true}}); err != nil {
|
||||
return err
|
||||
}
|
||||
return stream.CloseSend()
|
||||
}
|
||||
|
||||
func receivePluginResponseBody(stream pluginv1.TransportPlugin_ForwardClient, writer *io.PipeWriter, sendErr <-chan error) {
|
||||
defer func() { _ = writer.Close() }()
|
||||
for {
|
||||
frame, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
_ = writer.CloseWithError(normalizePluginRPCError(stream.Context(), "接收插件响应体", err, true))
|
||||
return
|
||||
}
|
||||
if chunk := frame.GetBodyChunk(); len(chunk) > 0 {
|
||||
if _, err := writer.Write(chunk); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if frame.GetEnd() != nil {
|
||||
select {
|
||||
case err := <-sendErr:
|
||||
if err != nil {
|
||||
_ = writer.CloseWithError(normalizePluginRPCError(stream.Context(), "发送插件请求体", err, true))
|
||||
}
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
if frameError := frame.GetError(); frameError != nil {
|
||||
_ = writer.CloseWithError(&PluginTransportError{Code: frameError.Code, Message: frameError.Message, RequestSent: frameError.RequestSent})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type pluginResponseBody struct {
|
||||
reader *io.PipeReader
|
||||
cancel context.CancelFunc
|
||||
done func()
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (b *pluginResponseBody) Read(data []byte) (int, error) {
|
||||
return b.reader.Read(data)
|
||||
}
|
||||
|
||||
func (b *pluginResponseBody) Close() error {
|
||||
var err error
|
||||
b.once.Do(func() {
|
||||
b.cancel()
|
||||
err = b.reader.Close()
|
||||
b.done()
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func headersToPlugin(headers http.Header) map[string]*pluginv1.HeaderValues {
|
||||
out := make(map[string]*pluginv1.HeaderValues, len(headers))
|
||||
for key, values := range headers {
|
||||
out[key] = &pluginv1.HeaderValues{Values: append([]string(nil), values...)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func headersFromPlugin(headers map[string]*pluginv1.HeaderValues) http.Header {
|
||||
out := make(http.Header, len(headers))
|
||||
for key, values := range headers {
|
||||
if values != nil {
|
||||
out[key] = append([]string(nil), values.Values...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type pluginFailingRequestBody struct{}
|
||||
|
||||
func (pluginFailingRequestBody) Read([]byte) (int, error) {
|
||||
return 0, errors.New("测试请求体读取失败")
|
||||
}
|
||||
func (pluginFailingRequestBody) Close() error { return nil }
|
||||
|
||||
func TestPluginRuntimeIntegration(t *testing.T) {
|
||||
packagePath := os.Getenv("SUB2API_TEST_PLUGIN_PACKAGE")
|
||||
if packagePath == "" {
|
||||
t.Skip("未提供 SUB2API_TEST_PLUGIN_PACKAGE,跳过本地插件进程集成测试")
|
||||
}
|
||||
packageFile, err := os.Open(packagePath)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = packageFile.Close() }()
|
||||
|
||||
root := t.TempDir()
|
||||
cfg := testPluginConfig(root, false)
|
||||
installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179", BuildType: "release"})
|
||||
installation, err := installer.Install(context.Background(), packageFile, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, PluginSignatureTrusted, installation.SignatureStatus)
|
||||
for _, relative := range []string{
|
||||
"ui/index.html",
|
||||
"ui/assets/styles.css",
|
||||
"ui/assets/bridge-v1.js",
|
||||
"ui/assets/app.js",
|
||||
} {
|
||||
assert.FileExists(t, filepath.Join(installation.InstallPath, filepath.FromSlash(relative)))
|
||||
}
|
||||
|
||||
runtime, err := startPluginRuntime(context.Background(), installation, 10*time.Second, filepath.Join(root, "runtime"))
|
||||
require.NoError(t, err)
|
||||
defer runtime.kill()
|
||||
require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{
|
||||
"request_timeout_seconds":30,
|
||||
"response_header_timeout_seconds":10,
|
||||
"idle_connection_timeout_seconds":30,
|
||||
"max_idle_connections":10,
|
||||
"max_idle_connections_per_host":5,
|
||||
"enable_http2":true,
|
||||
"tls_min_version":"1.2",
|
||||
"proxy_mode":"disabled",
|
||||
"extra_headers":{"X-Plugin-Test":"enabled"}
|
||||
}`)))
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
body, readErr := io.ReadAll(request.Body)
|
||||
require.NoError(t, readErr)
|
||||
assert.Equal(t, "payload", string(body))
|
||||
assert.Equal(t, "enabled", request.Header.Get("X-Plugin-Test"))
|
||||
assert.Equal(t, int64(len("payload")), request.ContentLength)
|
||||
assert.Empty(t, request.TransferEncoding)
|
||||
writer.Header().Set("X-Upstream", "plugin-runtime")
|
||||
writer.WriteHeader(http.StatusCreated)
|
||||
_, _ = writer.Write([]byte("response-through-plugin"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, upstream.URL, bytes.NewBufferString("payload"))
|
||||
require.NoError(t, err)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1}
|
||||
require.True(t, runtime.beginRequest())
|
||||
response, err := runtime.roundTrip(context.Background(), request, "", account)
|
||||
if err != nil {
|
||||
runtime.finishRequest()
|
||||
}
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(response.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusCreated, response.StatusCode)
|
||||
assert.Equal(t, "plugin-runtime", response.Header.Get("X-Upstream"))
|
||||
assert.Equal(t, "response-through-plugin", string(body))
|
||||
assert.Equal(t, int64(len("response-through-plugin")), response.ContentLength)
|
||||
require.NoError(t, response.Body.Close())
|
||||
|
||||
failingRequest, err := http.NewRequestWithContext(context.Background(), http.MethodPost, upstream.URL, pluginFailingRequestBody{})
|
||||
require.NoError(t, err)
|
||||
failingRequest.ContentLength = -1
|
||||
require.True(t, runtime.beginRequest())
|
||||
started := time.Now()
|
||||
_, err = runtime.roundTrip(context.Background(), failingRequest, "", account)
|
||||
runtime.finishRequest()
|
||||
require.Error(t, err)
|
||||
assert.Less(t, time.Since(started), 2*time.Second)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type pluginTokenRepository struct {
|
||||
PluginRepository
|
||||
installation *PluginInstallation
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (r *pluginTokenRepository) List(context.Context) ([]*PluginInstallation, error) {
|
||||
if r.listErr != nil {
|
||||
return nil, r.listErr
|
||||
}
|
||||
if r.installation == nil {
|
||||
return nil, nil
|
||||
}
|
||||
copy := *r.installation
|
||||
return []*PluginInstallation{©}, nil
|
||||
}
|
||||
|
||||
func (r *pluginTokenRepository) GetByID(context.Context, int64) (*PluginInstallation, error) {
|
||||
if r.installation == nil {
|
||||
return nil, errors.New("插件不存在")
|
||||
}
|
||||
copy := *r.installation
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
type pluginTokenEncryptor struct{}
|
||||
|
||||
func (pluginTokenEncryptor) Encrypt(plaintext string) (string, error) {
|
||||
return "ENC:" + plaintext, nil
|
||||
}
|
||||
|
||||
func (pluginTokenEncryptor) Decrypt(ciphertext string) (string, error) {
|
||||
if !strings.HasPrefix(ciphertext, "ENC:") {
|
||||
return "", errors.New("密文无效")
|
||||
}
|
||||
return strings.TrimPrefix(ciphertext, "ENC:"), nil
|
||||
}
|
||||
|
||||
func TestPluginUIAssetTokenCanBeResolvedByAnotherInstance(t *testing.T) {
|
||||
repo := &pluginTokenRepository{installation: &PluginInstallation{ID: 42}}
|
||||
first := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}}
|
||||
second := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}}
|
||||
|
||||
token, expires, err := first.CreateUIAssetToken(context.Background(), 42, 30*time.Minute)
|
||||
require.NoError(t, err)
|
||||
require.WithinDuration(t, time.Now().Add(30*time.Minute), expires, time.Second)
|
||||
|
||||
pluginID, err := second.ResolveUIAssetToken(token)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(42), pluginID)
|
||||
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(token)
|
||||
require.NoError(t, err)
|
||||
decoded[len(decoded)-1] ^= 1
|
||||
_, err = second.ResolveUIAssetToken(base64.RawURLEncoding.EncodeToString(decoded))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPluginUIAssetTokenRejectsOtherEncryptedPayloads(t *testing.T) {
|
||||
repo := &pluginTokenRepository{installation: &PluginInstallation{ID: 42}}
|
||||
manager := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}}
|
||||
|
||||
encrypted, err := manager.encryptor.Encrypt(`{"version":1,"plugin_id":42,"expires":4102444800}`)
|
||||
require.NoError(t, err)
|
||||
token := base64.RawURLEncoding.EncodeToString([]byte(encrypted))
|
||||
|
||||
_, err = manager.ResolveUIAssetToken(token)
|
||||
require.ErrorContains(t, err, "会话无效")
|
||||
}
|
||||
|
||||
func TestPluginReconcileFailsClosedWhenDesiredStateCannotBeRead(t *testing.T) {
|
||||
manager := &PluginManager{
|
||||
repo: &pluginTokenRepository{listErr: errors.New("数据库不可用")},
|
||||
runtimes: make(map[int64]*pluginRuntime),
|
||||
localInstallations: make(map[int64]*PluginInstallation),
|
||||
}
|
||||
|
||||
err := manager.reconcileOnce(context.Background())
|
||||
require.ErrorContains(t, err, "读取插件启用状态")
|
||||
require.True(t, manager.ShouldRouteOpenAIOAuth(&Account{
|
||||
ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth,
|
||||
}))
|
||||
|
||||
request, requestErr := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil)
|
||||
require.NoError(t, requestErr)
|
||||
_, handled, routeErr := manager.RoundTripOpenAIOAuth(context.Background(), request, "", &Account{
|
||||
ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth,
|
||||
})
|
||||
require.True(t, handled)
|
||||
require.ErrorContains(t, routeErr, "插件不可用")
|
||||
}
|
||||
|
||||
type normalizingPluginClient struct {
|
||||
pluginv1.TransportPluginClient
|
||||
normalized []byte
|
||||
applied []byte
|
||||
}
|
||||
|
||||
type pluginConfigRepository struct {
|
||||
PluginRepository
|
||||
installation *PluginInstallation
|
||||
encrypted string
|
||||
}
|
||||
|
||||
func (r *pluginConfigRepository) GetByID(context.Context, int64) (*PluginInstallation, error) {
|
||||
copy := *r.installation
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (r *pluginConfigRepository) UpdateConfig(_ context.Context, _ int64, encrypted, expectedBinarySHA256 string) error {
|
||||
if expectedBinarySHA256 != r.installation.BinarySHA256 {
|
||||
return ErrPluginStateChanged
|
||||
}
|
||||
r.encrypted = encrypted
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *normalizingPluginClient) ValidateConfig(context.Context, *pluginv1.ValidateConfigRequest, ...grpc.CallOption) (*pluginv1.ValidateConfigResponse, error) {
|
||||
return &pluginv1.ValidateConfigResponse{Valid: true, NormalizedConfigJson: c.normalized}, nil
|
||||
}
|
||||
|
||||
func (c *normalizingPluginClient) ApplyConfig(_ context.Context, request *pluginv1.ApplyConfigRequest, _ ...grpc.CallOption) (*pluginv1.ApplyConfigResponse, error) {
|
||||
c.applied = append([]byte(nil), request.ConfigJson...)
|
||||
return &pluginv1.ApplyConfigResponse{Applied: true}, nil
|
||||
}
|
||||
|
||||
func TestPluginRuntimeReturnsAndAppliesNormalizedConfig(t *testing.T) {
|
||||
client := &normalizingPluginClient{normalized: []byte(`{"z":2,"a":1}`)}
|
||||
runtime := &pluginRuntime{api: client}
|
||||
|
||||
normalized, err := runtime.validateAndApplyNormalizedConfig(context.Background(), []byte(`{"input":true}`))
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"a":1,"z":2}`, string(normalized))
|
||||
require.Equal(t, normalized, client.applied)
|
||||
}
|
||||
|
||||
func TestPluginRuntimeRejectsInvalidNormalizedConfig(t *testing.T) {
|
||||
client := &normalizingPluginClient{normalized: []byte(`{"broken"`)}
|
||||
runtime := &pluginRuntime{api: client}
|
||||
|
||||
_, err := runtime.validateAndApplyNormalizedConfig(context.Background(), []byte(`{}`))
|
||||
require.ErrorContains(t, err, "规范化配置")
|
||||
require.Empty(t, client.applied)
|
||||
}
|
||||
|
||||
func TestPluginManagerPersistsPluginNormalizedConfig(t *testing.T) {
|
||||
installation := &PluginInstallation{ID: 9, BinarySHA256: strings.Repeat("a", 64)}
|
||||
repo := &pluginConfigRepository{installation: installation}
|
||||
client := &normalizingPluginClient{normalized: []byte(`{"timeout":30,"enabled":true}`)}
|
||||
manager := &PluginManager{
|
||||
repo: repo, encryptor: pluginTokenEncryptor{},
|
||||
runtimes: map[int64]*pluginRuntime{9: {installation: installation, api: client}},
|
||||
}
|
||||
|
||||
saved, err := manager.SaveConfig(context.Background(), 9, []byte(`{"enabled":false}`))
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"enabled":true,"timeout":30}`, string(saved))
|
||||
plaintext, err := (pluginTokenEncryptor{}).Decrypt(repo.encrypted)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, string(saved), plaintext)
|
||||
}
|
||||
|
||||
func TestPluginRequestSentErrorDoesNotFailOver(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
account := &Account{ID: 7, Name: "oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
transportErr := &PluginTransportError{Code: "UPSTREAM_EOF", Message: "eof", RequestSent: true}
|
||||
|
||||
result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, transportErr, true)
|
||||
|
||||
require.Same(t, transportErr, result)
|
||||
var failover *UpstreamFailoverError
|
||||
require.False(t, errors.As(result, &failover))
|
||||
}
|
||||
|
||||
func TestPluginRPCAmbiguityPreventsReplayAfterMetadataDelivery(t *testing.T) {
|
||||
err := normalizePluginRPCError(context.Background(), "接收插件响应头", errors.New("连接已断开"), true)
|
||||
var transportErr *PluginTransportError
|
||||
require.ErrorAs(t, err, &transportErr)
|
||||
require.True(t, transportErr.RequestSent)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, err, true)
|
||||
require.Same(t, err, result)
|
||||
}
|
||||
|
||||
func TestPluginRPCFailureBeforeStreamCreationAllowsFailover(t *testing.T) {
|
||||
err := normalizePluginRPCError(context.Background(), "创建插件转发流", errors.New("连接失败"), false)
|
||||
var transportErr *PluginTransportError
|
||||
require.ErrorAs(t, err, &transportErr)
|
||||
require.False(t, transportErr.RequestSent)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
account := &Account{ID: 13, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, err, true)
|
||||
var failover *UpstreamFailoverError
|
||||
require.ErrorAs(t, result, &failover)
|
||||
}
|
||||
|
||||
func TestNormalizePluginRPCErrorPreservesCallerCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := normalizePluginRPCError(ctx, "接收响应", errors.New("rpc error: code = Canceled"), true)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
|
||||
func TestPluginStartingStateUsesBoundedCrashRecoveryWindow(t *testing.T) {
|
||||
manager := &PluginManager{cfg: &config.Config{Plugins: config.PluginConfig{StartTimeoutSeconds: 15}}}
|
||||
|
||||
require.False(t, manager.startingStateExpired(&PluginInstallation{UpdatedAt: time.Now().Add(-30 * time.Second)}))
|
||||
require.True(t, manager.startingStateExpired(&PluginInstallation{UpdatedAt: time.Now().Add(-2 * time.Minute)}))
|
||||
}
|
||||
@@ -201,9 +201,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
||||
SettingKeyAvailableChannelsEnabled: "false",
|
||||
|
||||
// Model plaza feature (default disabled; opt-in, public unless require_auth)
|
||||
SettingKeyModelPlazaEnabled: "false",
|
||||
SettingKeyModelPlazaRequireAuth: "false",
|
||||
SettingKeyModelPlazaDescription: "",
|
||||
SettingKeyModelPlazaEnabled: "false",
|
||||
SettingKeyModelPlazaRequireAuth: "false",
|
||||
SettingKeyModelPlazaDescription: "",
|
||||
SettingKeyPluginManagementEnabled: "false",
|
||||
|
||||
// Affiliate (邀请返利) feature (default disabled; opt-in)
|
||||
SettingKeyAffiliateEnabled: "false",
|
||||
@@ -820,6 +821,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
result.ModelPlazaEnabled = settings[SettingKeyModelPlazaEnabled] == "true"
|
||||
result.ModelPlazaRequireAuth = settings[SettingKeyModelPlazaRequireAuth] == "true"
|
||||
result.ModelPlazaDescription = settings[SettingKeyModelPlazaDescription]
|
||||
result.PluginManagementEnabled = settings[SettingKeyPluginManagementEnabled] == "true"
|
||||
|
||||
// Affiliate (邀请返利) feature (default: disabled; strict true)
|
||||
result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true"
|
||||
|
||||
@@ -235,6 +235,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
SettingKeyAvailableChannelsEnabled,
|
||||
SettingKeyModelPlazaEnabled,
|
||||
SettingKeyModelPlazaRequireAuth,
|
||||
SettingKeyPluginManagementEnabled,
|
||||
SettingKeyAffiliateEnabled,
|
||||
SettingKeyRiskControlEnabled,
|
||||
SettingKeyAllowUserViewErrorRequests,
|
||||
@@ -360,8 +361,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
|
||||
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
|
||||
|
||||
ModelPlazaEnabled: settings[SettingKeyModelPlazaEnabled] == "true",
|
||||
ModelPlazaRequireAuth: settings[SettingKeyModelPlazaRequireAuth] == "true",
|
||||
ModelPlazaEnabled: settings[SettingKeyModelPlazaEnabled] == "true",
|
||||
ModelPlazaRequireAuth: settings[SettingKeyModelPlazaRequireAuth] == "true",
|
||||
PluginManagementEnabled: settings[SettingKeyPluginManagementEnabled] == "true",
|
||||
|
||||
AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
|
||||
|
||||
@@ -620,6 +622,7 @@ type PublicSettingsInjectionPayload struct {
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||
RiskControlEnabled bool `json:"risk_control_enabled"`
|
||||
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
|
||||
@@ -700,6 +703,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
PluginManagementEnabled: settings.PluginManagementEnabled,
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
RiskControlEnabled: settings.RiskControlEnabled,
|
||||
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
|
||||
|
||||
@@ -435,6 +435,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
||||
updates[SettingKeyModelPlazaEnabled] = strconv.FormatBool(settings.ModelPlazaEnabled)
|
||||
updates[SettingKeyModelPlazaRequireAuth] = strconv.FormatBool(settings.ModelPlazaRequireAuth)
|
||||
updates[SettingKeyModelPlazaDescription] = settings.ModelPlazaDescription
|
||||
updates[SettingKeyPluginManagementEnabled] = strconv.FormatBool(settings.PluginManagementEnabled)
|
||||
|
||||
// Affiliate (邀请返利) feature switch
|
||||
updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled)
|
||||
|
||||
@@ -212,9 +212,10 @@ type SystemSettings struct {
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
// Model Plaza feature (public group/model pricing showcase)
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaDescription string `json:"model_plaza_description"`
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaDescription string `json:"model_plaza_description"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
|
||||
// Claude Code version check
|
||||
MinClaudeCodeVersion string
|
||||
@@ -390,8 +391,9 @@ type PublicSettings struct {
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
// Model Plaza feature (public group/model pricing showcase)
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
|
||||
// Affiliate (邀请返利) feature toggle
|
||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||
|
||||
@@ -231,6 +231,7 @@ func ProvideAccountTestService(
|
||||
tlsFPProfileService *TLSFingerprintProfileService,
|
||||
openAIGatewayService *OpenAIGatewayService,
|
||||
settingService *SettingService,
|
||||
pluginManager *PluginManager,
|
||||
) *AccountTestService {
|
||||
service := NewAccountTestService(
|
||||
accountRepo,
|
||||
@@ -244,6 +245,7 @@ func ProvideAccountTestService(
|
||||
)
|
||||
service.agentIdentityWS = openAIGatewayService
|
||||
service.SetSettingService(settingService)
|
||||
service.SetPluginManager(pluginManager)
|
||||
return service
|
||||
}
|
||||
|
||||
@@ -895,6 +897,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewTotpService,
|
||||
NewErrorPassthroughService,
|
||||
NewTLSFingerprintProfileService,
|
||||
NewPluginManager,
|
||||
NewDigestSessionStore,
|
||||
ProvideIdempotencyCoordinator,
|
||||
ProvideSystemOperationLockService,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- 管理员手动上传的本地进程插件。
|
||||
-- 插件默认停用;账号表不保存任何插件字段。
|
||||
-- 表名使用 sub2api 前缀,避免与部署数据库中已有的通用插件表冲突。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sub2api_plugin_installations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
plugin_key VARCHAR(160) NOT NULL UNIQUE,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
version VARCHAR(64) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
author VARCHAR(160) NOT NULL DEFAULT '',
|
||||
manifest JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
artifact_path TEXT NOT NULL,
|
||||
install_path TEXT NOT NULL,
|
||||
binary_path TEXT NOT NULL,
|
||||
binary_sha256 VARCHAR(64) NOT NULL,
|
||||
signature_status VARCHAR(32) NOT NULL DEFAULT 'unsigned',
|
||||
state VARCHAR(32) NOT NULL DEFAULT 'disabled',
|
||||
config_encrypted TEXT NOT NULL DEFAULT '',
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
installed_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
enabled_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT sub2api_plugin_installations_state_check
|
||||
CHECK (state IN ('disabled', 'starting', 'enabled', 'error', 'incompatible')),
|
||||
CONSTRAINT sub2api_plugin_installations_signature_status_check
|
||||
CHECK (signature_status IN ('trusted', 'unsigned'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sub2api_plugin_bindings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
plugin_id BIGINT NOT NULL REFERENCES sub2api_plugin_installations(id) ON DELETE CASCADE,
|
||||
capability VARCHAR(160) NOT NULL,
|
||||
platform VARCHAR(32) NOT NULL,
|
||||
account_type VARCHAR(32) NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
rollout_percent INTEGER NOT NULL DEFAULT 100,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT sub2api_plugin_bindings_rollout_check CHECK (rollout_percent BETWEEN 0 AND 100),
|
||||
CONSTRAINT sub2api_plugin_bindings_scope_unique UNIQUE (plugin_id, capability, platform, account_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sub2api_plugin_bindings_plugin_id
|
||||
ON sub2api_plugin_bindings(plugin_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sub2api_plugin_bindings_enabled_scope
|
||||
ON sub2api_plugin_bindings(platform, account_type, capability)
|
||||
WHERE enabled = TRUE;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sub2api_plugin_bindings_one_enabled_scope
|
||||
ON sub2api_plugin_bindings(capability, platform, account_type)
|
||||
WHERE enabled = TRUE;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 保存经过签名校验的原始插件包,供多实例和无状态节点重新复验、解包。
|
||||
-- 旧安装记录允许暂时为空;管理员重新上传该插件后会自动补齐。
|
||||
ALTER TABLE sub2api_plugin_installations
|
||||
ADD COLUMN IF NOT EXISTS artifact_data BYTEA;
|
||||
@@ -0,0 +1,35 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPluginsMigrationKeepsAccountSchemaUnchanged(t *testing.T) {
|
||||
content, err := FS.ReadFile("229_plugins.sql")
|
||||
require.NoError(t, err)
|
||||
|
||||
sql := strings.Join(strings.Fields(string(content)), " ")
|
||||
require.Contains(t, sql, "CREATE TABLE IF NOT EXISTS sub2api_plugin_installations")
|
||||
require.Contains(t, sql, "CREATE TABLE IF NOT EXISTS sub2api_plugin_bindings")
|
||||
require.Contains(t, sql, "config_encrypted TEXT NOT NULL DEFAULT ''")
|
||||
require.Contains(t, sql, "REFERENCES sub2api_plugin_installations(id)")
|
||||
require.Contains(t, sql, "CREATE UNIQUE INDEX IF NOT EXISTS idx_sub2api_plugin_bindings_one_enabled_scope")
|
||||
require.Contains(t, sql, "WHERE enabled = TRUE")
|
||||
require.NotContains(t, sql, "CREATE TABLE IF NOT EXISTS plugin_installations")
|
||||
require.NotContains(t, sql, "CREATE TABLE IF NOT EXISTS plugin_bindings")
|
||||
require.NotContains(t, strings.ToUpper(sql), "ALTER TABLE ACCOUNTS")
|
||||
require.NotContains(t, sql, "account_id")
|
||||
}
|
||||
|
||||
func TestPluginArtifactMigrationSupportsExistingInstallations(t *testing.T) {
|
||||
content, err := FS.ReadFile("230_plugin_artifacts.sql")
|
||||
require.NoError(t, err)
|
||||
|
||||
sql := strings.Join(strings.Fields(string(content)), " ")
|
||||
require.Contains(t, sql, "ALTER TABLE sub2api_plugin_installations")
|
||||
require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS artifact_data BYTEA")
|
||||
require.NotContains(t, strings.ToUpper(sql), "ALTER TABLE ACCOUNTS")
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Sub2API 本地插件协议
|
||||
|
||||
本目录是插件开发者可以依赖的公开契约。`v1/plugin.proto` 和 `v1/runtime.go` 定义进程协议,`v1/manifest.schema.json` 定义包清单,`docs/` 记录开发和发布规范。Provider 私有实现不应放入本目录。
|
||||
|
||||
## 开发文档
|
||||
|
||||
- [开发指南](docs/development.md):从运行时、配置到集成测试的完整流程。
|
||||
- [UI Bridge](docs/ui-bridge.md):沙箱配置 UI 的消息结构和安全要求。
|
||||
- [包格式](docs/package-format.md):清单、文件哈希、签名和版本规则。
|
||||
- [安全边界](docs/security.md):进程权限、敏感数据和故障策略。
|
||||
|
||||
## 实体与运行方式
|
||||
|
||||
插件的交付实体是一个 `.s2plugin` 文件,本质上是带清单、签名、独立可执行文件和静态 UI 的 ZIP 包。管理员在独立的插件管理页手动上传,Sub2API 不从网络自动下载插件,也不要求 Docker。
|
||||
|
||||
启用后,Sub2API 以子进程方式拉起当前操作系统和 CPU 架构对应的二进制,通过本机 gRPC 流传递请求与响应。插件进程退出时会随 Sub2API 清理;停用时先停止接收新请求,再等待正在处理的请求结束。
|
||||
|
||||
多实例部署不要求共享插件目录。宿主会在数据库保存已验签的原始插件包,各实例缺少本地文件时会重新验签和解包,并周期性对齐启用状态、灰度比例和加密配置。所有实例必须连接同一数据库并使用相同的加密密钥。
|
||||
|
||||
独立进程是代码和发布边界,不是操作系统安全沙箱。插件拥有 Sub2API 服务用户所拥有的文件和网络权限,因此只应安装可信发布者的签名包。闭源二进制可提高源码分发门槛,但不能承诺无法反编译。
|
||||
|
||||
## 初期能力边界
|
||||
|
||||
当前只接受 `openai.oauth.outbound_transport.v1`:
|
||||
|
||||
- 仅匹配 `platform=openai` 且 `account_type=oauth` 的上游 HTTP 请求。
|
||||
- API Key 账号、其他 provider、OAuth 登录与 Token 刷新流程不进入插件。
|
||||
- 插件建立真实的上游 HTTP/TLS 连接并返回原始 HTTP 响应。
|
||||
- 命中插件的 OAuth WebSocket 账号会使用 Sub2API 现有 HTTP Bridge,不直接建立上游 WebSocket,避免绕过 v1 HTTP 插件协议。
|
||||
- Sub2API 继续负责响应状态处理、SSE 解析、错误映射、用量统计、计费和下游输出。
|
||||
- 灰度比例以账号 ID 稳定分桶,未命中的 OAuth 账号继续使用原有内置路径。
|
||||
|
||||
## 包结构
|
||||
|
||||
```text
|
||||
manifest.json
|
||||
signature.json # 生产包必需
|
||||
runtimes/linux-amd64/plugin
|
||||
runtimes/linux-arm64/plugin
|
||||
runtimes/windows-amd64/plugin.exe
|
||||
ui/index.html
|
||||
ui/assets/...
|
||||
```
|
||||
|
||||
`manifest.json` 必须声明所有运行时和 UI 文件的 SHA-256。`signature.json` 使用受信任发布者的 Ed25519 私钥对 `manifest.json` 原始字节签名。官方 OpenAI Transport 公钥由宿主内置,第三方发布者公钥由部署者追加到 `plugins.trusted_publishers`。文件哈希由已签名清单保护。
|
||||
|
||||
插件默认保持停用。未签名包默认拒绝安装;`plugins.allow_unsigned` 只应用于开发者自己构建的本地调试包。
|
||||
|
||||
## 兼容性
|
||||
|
||||
清单必须同时声明:
|
||||
|
||||
- `requires.sub2api`:允许的 Sub2API 语义化版本范围。
|
||||
- `requires.recommended_sub2api_version`:建议使用的宿主版本。
|
||||
- `requires.tested_sub2api_versions`:发布者实际验证过的宿主版本。
|
||||
- `plugin_protocol`、`transport_api`、`ui_bridge`:三个独立协议版本。
|
||||
|
||||
宿主版本超出范围时,插件可以安装并查看,但保持“不兼容”状态且不能启用。版本在范围内但未列入已测试版本时,管理员必须再次确认才能启用。
|
||||
|
||||
## UI 隔离与 Bridge
|
||||
|
||||
插件 UI 由包内静态文件实现,宿主使用只有 `allow-scripts` 权限的 sandbox iframe 加载。iframe 没有管理员 Token,也不能直接访问管理 API。宿主为每次打开配置页生成短时资源 URL 和独立 Bridge Token,并且同时校验消息来源窗口与 Token。
|
||||
|
||||
UI 可以发送以下消息:
|
||||
|
||||
- `config.load`
|
||||
- `config.save`
|
||||
- `config.test`
|
||||
- `ui.resize`
|
||||
- `ui.notify`
|
||||
|
||||
每个请求消息带 `request_id`,宿主以 `<type>.result` 返回结果。配置整体使用 Sub2API 的密钥加密后存入数据库;运行中插件会先验证并应用新配置,数据库写入失败时恢复旧配置。
|
||||
|
||||
## 协议源码
|
||||
|
||||
- `v1/plugin.proto`:稳定的进程间消息定义。
|
||||
- `v1/runtime.go`:Go 插件进程启动入口和宿主客户端声明。
|
||||
- `v1/manifest.schema.json`:`manifest.json` 的 JSON Schema。
|
||||
|
||||
插件通过进程协议协作,不使用 Go 动态链接,也不要求插件与 Sub2API 使用相同编译器或共享内存 ABI。
|
||||
@@ -0,0 +1,67 @@
|
||||
# 插件开发指南
|
||||
|
||||
## 稳定边界
|
||||
|
||||
当前宿主只支持 `openai.oauth.outbound_transport.v1`。插件负责建立实际上游 HTTP/TLS 连接,Sub2API 负责账号选择、OAuth Token 生命周期、下游协议、响应解析、SSE、错误映射、用量统计和计费。
|
||||
|
||||
插件不应修改 API Key 路径,也不应自行刷新或持久化 OAuth Token。
|
||||
|
||||
## 推荐结构
|
||||
|
||||
```text
|
||||
plugin/
|
||||
├── cmd/<plugin>/main.go
|
||||
├── internal/config/
|
||||
├── internal/transport/
|
||||
├── ui/index.html
|
||||
├── ui/assets/
|
||||
├── tools/packager/
|
||||
├── manifest.source.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
入口只调用 `pluginv1.Serve`。配置解析和传输实现放入独立包,以便不启动子进程就能单元测试。
|
||||
|
||||
## 运行时方法
|
||||
|
||||
| 方法 | 要求 |
|
||||
|---|---|
|
||||
| `GetInfo` | ID、版本、协议和能力必须与清单一致 |
|
||||
| `Health` | 返回进程是否可以接受新请求,不执行昂贵探测 |
|
||||
| `ValidateConfig` | 严格解析并返回完整规范化 JSON |
|
||||
| `ApplyConfig` | 原子应用配置;失败时保留旧配置 |
|
||||
| `TestConfig` | 验证当前环境和已保存配置,返回简短诊断 |
|
||||
| `Forward` | 双向流式传输请求与原始 HTTP 响应 |
|
||||
|
||||
请求帧顺序:`start`、零到多个 `body_chunk`、`body_end`。响应帧顺序:`start`、零到多个 `body_chunk`、`end`。不能继续处理的错误使用 `error` 帧。
|
||||
|
||||
`request_sent` 必须如实表示请求是否可能已经到达上游。值为 `true` 时宿主禁止自动切换账号重放;只有能确认尚未调用上游 Transport 时才能返回 `false`。
|
||||
|
||||
## 配置
|
||||
|
||||
- JSON 字段统一使用 `snake_case`。
|
||||
- 拒绝未知字段、非法范围和受保护请求头。
|
||||
- 默认配置必须完整,空对象应规范化为所有默认字段。
|
||||
- 保存时由插件先验证和应用,再由宿主加密写入数据库。
|
||||
- 数据库写入失败时宿主会尝试恢复旧配置,插件必须允许重复应用。
|
||||
|
||||
## 资源管理
|
||||
|
||||
- 复用 HTTP Transport 和连接池,不要为每个请求创建新连接池。
|
||||
- 配置切换后关闭旧空闲连接。
|
||||
- 使用 stream context 取消 DNS、连接、上传和响应读取。
|
||||
- 始终关闭上游响应体。
|
||||
- 不在插件内无限缓存按账号区分的客户端。
|
||||
|
||||
## 最低测试集
|
||||
|
||||
- 配置默认值、未知字段、边界值和深复制。
|
||||
- 插件身份及协议版本。
|
||||
- 请求体分块、无请求体、固定 Content-Length。
|
||||
- 响应状态、重复请求头、流式响应和响应读取错误。
|
||||
- 上下文取消、插件退出和超时。
|
||||
- 代理开启与禁用。
|
||||
- 包哈希、签名、路径穿越和目标平台运行时。
|
||||
- UI Bridge 加载、保存、测试、错误和超时。
|
||||
|
||||
发布前还应使用真实构建包运行宿主的插件进程集成测试。
|
||||
@@ -0,0 +1,45 @@
|
||||
# `.s2plugin` 包格式
|
||||
|
||||
`.s2plugin` 是 ZIP 文件,根目录必须包含 `manifest.json`,生产包还必须包含 `signature.json`。
|
||||
|
||||
## 标准布局
|
||||
|
||||
```text
|
||||
manifest.json
|
||||
signature.json
|
||||
runtimes/<goos>-<goarch>/<binary>
|
||||
ui/index.html
|
||||
ui/assets/...
|
||||
```
|
||||
|
||||
所有运行时和 UI 文件必须出现在 `manifest.files`,值为小写十六进制 SHA-256。清单和签名文件自身不写入 `files`。
|
||||
|
||||
包不允许绝对路径、父目录跳转、重复路径、符号链接、未声明文件或缺失文件。宿主还限制上传大小、解压后大小和文件数量。
|
||||
|
||||
## 清单
|
||||
|
||||
字段规范见 [`v1/manifest.schema.json`](../v1/manifest.schema.json)。版本字段含义:
|
||||
|
||||
- `version`:插件自身语义化版本。
|
||||
- `requires.sub2api`:宿主硬兼容范围。
|
||||
- `recommended_sub2api_version`:建议宿主版本。
|
||||
- `tested_sub2api_versions`:发布者真实验证过的版本。
|
||||
- `plugin_protocol`:进程握手协议。
|
||||
- `transport_api`:请求和响应帧协议。
|
||||
- `ui_bridge`:配置 UI 消息协议。
|
||||
|
||||
## 签名
|
||||
|
||||
`signature.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"algorithm": "ed25519",
|
||||
"key_id": "publisher-key-id",
|
||||
"signature": "BASE64_SIGNATURE"
|
||||
}
|
||||
```
|
||||
|
||||
签名对象是 `manifest.json` 的精确原始字节。发布者私钥不得进入插件包、源码仓库或 Sub2API 运行环境。部署者只配置 Base64 Ed25519 公钥。
|
||||
|
||||
默认生产配置拒绝未签名包。官方 OpenAI Transport 使用宿主内置公钥验签,不需要配置;其他发布者仍需配置 `trusted_publishers`。`allow_unsigned` 只用于开发者自己构建的本地包。
|
||||
@@ -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。
|
||||
@@ -0,0 +1,56 @@
|
||||
# UI Bridge v1
|
||||
|
||||
## 加载方式
|
||||
|
||||
宿主为每次打开配置页创建短时 UI 会话:
|
||||
|
||||
```text
|
||||
/api/v1/plugin-ui/<asset-token>/index.html#bridge_token=<bridge-token>
|
||||
```
|
||||
|
||||
资源 Token 用于读取包内 `ui/` 文件,Bridge Token 只存在于 URL fragment,不会发送到服务器。iframe 使用 `sandbox="allow-scripts"`,不授予 `allow-same-origin`。
|
||||
|
||||
UI 只能加载包内、已在清单声明的资源。CSP 禁止外部网络连接、表单提交和外部 frame。
|
||||
|
||||
## 消息信封
|
||||
|
||||
UI 到宿主:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "sub2api-plugin-ui",
|
||||
"bridge_token": "TOKEN",
|
||||
"type": "config.load",
|
||||
"request_id": "UNIQUE_ID"
|
||||
}
|
||||
```
|
||||
|
||||
宿主到 UI:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "sub2api-plugin-host",
|
||||
"bridge_token": "TOKEN",
|
||||
"request_id": "UNIQUE_ID",
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
## 方法
|
||||
|
||||
| `type` | UI 参数 | 成功响应 |
|
||||
|---|---|---|
|
||||
| `sub2api.plugin.ready` | 无 | 无响应 |
|
||||
| `config.load` | 无 | `config` |
|
||||
| `config.save` | `config` 对象 | 规范化后的 `config` |
|
||||
| `config.test` | 无 | `result` |
|
||||
| `ui.resize` | `height` | 无响应 |
|
||||
| `ui.notify` | `level`、`message` | 无响应 |
|
||||
|
||||
`config.test` 在 v1 中测试已保存配置。UI 若要测试当前表单,应先调用 `config.save`。
|
||||
|
||||
## 必须执行的校验
|
||||
|
||||
UI 接收消息时必须验证 `event.source === parent`、消息来源标识、Bridge Token 和等待中的 `request_id`。每个请求必须有超时和卸载清理。
|
||||
|
||||
宿主不会向 iframe 提供管理员 Token。插件 UI 不得尝试访问管理 API、Cookie、父页面 DOM 或浏览器存储中的宿主数据。
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sub2api.local/schemas/plugin-manifest-v1.json",
|
||||
"title": "Sub2API Plugin Manifest v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "id", "name", "version", "requires", "capabilities", "runtimes", "ui", "files"],
|
||||
"properties": {
|
||||
"schema_version": { "const": 1 },
|
||||
"id": { "type": "string", "maxLength": 160, "pattern": "^[a-z0-9]+([._-][a-z0-9]+)+$" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"version": { "type": "string", "pattern": "^v?(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$" },
|
||||
"description": { "type": "string" },
|
||||
"author": { "type": "string", "maxLength": 160 },
|
||||
"requires": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["sub2api", "plugin_protocol", "transport_api", "ui_bridge"],
|
||||
"properties": {
|
||||
"sub2api": { "type": "string", "minLength": 1 },
|
||||
"recommended_sub2api_version": { "type": "string" },
|
||||
"tested_sub2api_versions": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
|
||||
"plugin_protocol": { "const": 1 },
|
||||
"transport_api": { "const": 1 },
|
||||
"ui_bridge": { "const": 1 }
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "platform", "account_type"],
|
||||
"properties": {
|
||||
"id": { "const": "openai.oauth.outbound_transport.v1" },
|
||||
"platform": { "const": "openai" },
|
||||
"account_type": { "const": "oauth" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"runtimes": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"patternProperties": {
|
||||
"^[a-z0-9]+-[a-z0-9]+$": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path"],
|
||||
"properties": { "path": { "$ref": "#/$defs/safePath" } }
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ui": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["entrypoint"],
|
||||
"properties": { "entrypoint": { "type": "string", "pattern": "^ui/.+" } }
|
||||
},
|
||||
"files": {
|
||||
"type": "object",
|
||||
"minProperties": 2,
|
||||
"propertyNames": { "$ref": "#/$defs/safePath" },
|
||||
"additionalProperties": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"safePath": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package pluginv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManifestSchemaIsValidJSON(t *testing.T) {
|
||||
raw, err := os.ReadFile("manifest.schema.json")
|
||||
if err != nil {
|
||||
t.Fatalf("读取插件清单 Schema 失败: %v", err)
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("插件清单 Schema 不是有效 JSON: %v", err)
|
||||
}
|
||||
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" {
|
||||
t.Fatalf("插件清单 Schema 版本不符合预期: %v", schema["$schema"])
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package sub2api.plugin.v1;
|
||||
|
||||
option go_package = "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1;pluginv1";
|
||||
|
||||
service TransportPlugin {
|
||||
rpc GetInfo(GetInfoRequest) returns (GetInfoResponse);
|
||||
rpc Health(HealthRequest) returns (HealthResponse);
|
||||
rpc ValidateConfig(ValidateConfigRequest) returns (ValidateConfigResponse);
|
||||
rpc ApplyConfig(ApplyConfigRequest) returns (ApplyConfigResponse);
|
||||
rpc TestConfig(TestConfigRequest) returns (TestConfigResponse);
|
||||
rpc Forward(stream ForwardRequest) returns (stream ForwardResponse);
|
||||
}
|
||||
|
||||
message GetInfoRequest {}
|
||||
|
||||
message GetInfoResponse {
|
||||
string plugin_id = 1;
|
||||
string plugin_version = 2;
|
||||
uint32 protocol_version = 3;
|
||||
uint32 transport_api_version = 4;
|
||||
repeated string capabilities = 5;
|
||||
}
|
||||
|
||||
message HealthRequest {}
|
||||
|
||||
message HealthResponse {
|
||||
bool healthy = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message ValidateConfigRequest {
|
||||
bytes config_json = 1;
|
||||
}
|
||||
|
||||
message ValidateConfigResponse {
|
||||
bool valid = 1;
|
||||
string message = 2;
|
||||
bytes normalized_config_json = 3;
|
||||
}
|
||||
|
||||
message ApplyConfigRequest {
|
||||
bytes config_json = 1;
|
||||
}
|
||||
|
||||
message ApplyConfigResponse {
|
||||
bool applied = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message TestConfigRequest {
|
||||
bytes config_json = 1;
|
||||
}
|
||||
|
||||
message TestConfigResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
int64 latency_ms = 3;
|
||||
}
|
||||
|
||||
message HeaderValues {
|
||||
repeated string values = 1;
|
||||
}
|
||||
|
||||
message ForwardRequestStart {
|
||||
string request_id = 1;
|
||||
string method = 2;
|
||||
string url = 3;
|
||||
string host = 4;
|
||||
map<string, HeaderValues> headers = 5;
|
||||
string proxy_url = 6;
|
||||
int64 account_id = 7;
|
||||
int32 account_concurrency = 8;
|
||||
string platform = 9;
|
||||
string account_type = 10;
|
||||
int64 content_length = 11;
|
||||
bool has_body = 12;
|
||||
}
|
||||
|
||||
message ForwardRequest {
|
||||
oneof frame {
|
||||
ForwardRequestStart start = 1;
|
||||
bytes body_chunk = 2;
|
||||
bool body_end = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message ForwardResponseStart {
|
||||
int32 status_code = 1;
|
||||
string status = 2;
|
||||
string protocol = 3;
|
||||
int32 protocol_major = 4;
|
||||
int32 protocol_minor = 5;
|
||||
map<string, HeaderValues> headers = 6;
|
||||
int64 content_length = 7;
|
||||
}
|
||||
|
||||
message ForwardResponseEnd {
|
||||
int64 bytes_received = 1;
|
||||
int64 duration_ms = 2;
|
||||
}
|
||||
|
||||
message ForwardResponseError {
|
||||
string code = 1;
|
||||
string message = 2;
|
||||
bool request_sent = 3;
|
||||
}
|
||||
|
||||
message ForwardResponse {
|
||||
oneof frame {
|
||||
ForwardResponseStart start = 1;
|
||||
bytes body_chunk = 2;
|
||||
ForwardResponseEnd end = 3;
|
||||
ForwardResponseError error = 4;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
# 定价数据源(可选)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
# Sub2API 插件开发教程
|
||||
|
||||
本文面向希望为 Sub2API 开发、打包和发布插件的团队。插件是独立进程和静态 UI 组成的 `.s2plugin` 包,宿主通过稳定的 gRPC 协议调用它。本文以当前宿主已经定义的 `openai.oauth.outbound_transport.v1` 能力作为协议示例,说明开发者需要准备什么、哪些职责属于插件、哪些职责仍由 Sub2API 负责。
|
||||
|
||||
本文不是一个可直接安装的完整插件,也不代表 Sub2API 已经发布对应的官方插件包。当前文档主要描述公开协议、宿主边界和开发流程。后续是否发布可安装包、支持哪些 Provider,以及如何提供示例仓库,都需要另行公告。
|
||||
|
||||
## 1. 准备开发环境
|
||||
|
||||
建议使用以下环境:
|
||||
|
||||
- Go 1.21 或更高版本;
|
||||
- Node.js(仅在插件 UI 使用 JavaScript 时需要);
|
||||
- Git;
|
||||
- 与目标部署环境一致的构建工具链。
|
||||
|
||||
协议定义和通用说明位于:
|
||||
|
||||
- `backend/pkg/pluginapi/v1/plugin.proto`:进程间消息和流式请求定义;
|
||||
- `backend/pkg/pluginapi/v1/runtime.go`:插件进程启动入口;
|
||||
- `backend/pkg/pluginapi/v1/manifest.schema.json`:包清单 JSON Schema;
|
||||
- `backend/pkg/pluginapi/docs/`:开发、UI Bridge、包格式和安全边界说明。
|
||||
|
||||
目前暂未提供可直接复制的官方示例源码。开发者可以按照本文的目录和协议说明创建自己的插件工程;示例仓库发布后,会在本文补充正式的获取地址、目录说明和版本要求。公开协议始终以 `backend/pkg/pluginapi/` 为准。
|
||||
|
||||
## 2. 创建插件工程
|
||||
|
||||
在示例仓库发布前,可以先创建一个独立的 Go 工程,目录建议如下:
|
||||
|
||||
```text
|
||||
my-plugin/
|
||||
├── cmd/<plugin>/main.go
|
||||
├── internal/pluginconfig/
|
||||
├── internal/transport/
|
||||
├── ui/index.html
|
||||
├── ui/assets/
|
||||
├── tools/
|
||||
├── manifest.source.json
|
||||
└── build.sh
|
||||
```
|
||||
|
||||
开发时至少准备以下部分:
|
||||
|
||||
1. `manifest.source.json`:插件 ID、名称、版本、作者、能力和兼容的 Sub2API 版本;
|
||||
2. `cmd/<plugin>/main.go`:启动入口和运行时版本注入,并同步打包器中的构建目标和二进制名称;
|
||||
3. `internal/pluginconfig/`:配置结构、默认值、严格校验和规范化;
|
||||
4. `internal/transport/`:HTTP 客户端、代理、请求头、请求体、网络连接参数、响应流和资源回收;
|
||||
5. `ui/index.html` 与 `ui/assets/`:插件自己的配置界面;
|
||||
6. 单元测试、进程集成测试和目标平台构建配置。
|
||||
|
||||
入口文件应保持很小,只负责调用 `pluginv1.Serve`。实际逻辑放在可独立测试的包中,避免把配置解析、网络请求和协议组装全部写在 `main.go`。
|
||||
|
||||
## 3. 编写运行时
|
||||
|
||||
运行时实现 `TransportPlugin` 服务,必须满足以下约定:
|
||||
|
||||
| 方法 | 要求 |
|
||||
| --- | --- |
|
||||
| `GetInfo` | 返回的插件 ID、版本、协议版本、传输 API 版本和能力必须与清单一致。 |
|
||||
| `Health` | 快速返回进程是否可以接收新请求,不执行长时间网络探测。 |
|
||||
| `ValidateConfig` | 严格解析 JSON,拒绝未知字段和非法范围,并返回完整的规范化配置。 |
|
||||
| `ApplyConfig` | 成功后原子切换配置;失败时保留旧配置和旧连接。 |
|
||||
| `TestConfig` | 针对已保存配置进行快速诊断,返回简短、可展示的结果。 |
|
||||
| `Forward` | 按协议接收请求流,发出上游请求,再按顺序返回响应流。 |
|
||||
|
||||
请求帧顺序为 `start`、零到多个 `body_chunk`、`body_end`;响应帧顺序为 `start`、零到多个 `body_chunk`、`end`。不能继续处理时发送 `error` 帧。
|
||||
|
||||
`ForwardResponseError.request_sent` 必须准确:只有在能够确认尚未调用上游 HTTP Transport 时才返回 `false`;一旦已经调用,或无法确认上游是否收到请求,就返回 `true`。宿主会据此决定是否允许切换账号重试,避免重复执行同一个请求。
|
||||
|
||||
资源管理也属于运行时契约:复用 HTTP Transport 和连接池,配置切换时关闭旧空闲连接,沿用 gRPC stream 的 context 取消 DNS、连接、上传和响应读取,并始终关闭上游响应体。日志和错误消息不能包含 Token、代理凭据、完整请求体或敏感响应头。
|
||||
|
||||
## 4. 设计插件配置
|
||||
|
||||
插件配置由插件定义,由 Sub2API 加密保存。推荐流程是:
|
||||
|
||||
1. 在 `internal/pluginconfig.Config` 中定义字段和默认值;
|
||||
2. 使用 `json.Decoder.DisallowUnknownFields` 等严格方式解析;
|
||||
3. 将空对象规范化为完整默认配置;
|
||||
4. 在 `ValidateConfig` 和 `ApplyConfig` 中复用同一套校验;
|
||||
5. 配置应用成功后再让宿主保存,保存失败时允许恢复旧配置。
|
||||
|
||||
JSON 字段统一使用 `snake_case`。敏感配置不要放入 URL、UI 通知、诊断结果或日志。插件不应从 UI 读取、刷新或持久化 OAuth Token;宿主只在运行时调用需要的网络转发接口。
|
||||
|
||||
## 5. 实现插件自己的配置 UI
|
||||
|
||||
UI 是插件包内的静态页面,不需要修改 Sub2API 前端源码。宿主会在受限 iframe 中加载 `ui/index.html`,并通过 UI Bridge 提供配置读写和测试能力。
|
||||
|
||||
页面初始化流程:
|
||||
|
||||
1. 加载包内 HTML、CSS 和 JavaScript;
|
||||
2. 创建 Bridge 并注册 `message` 监听;
|
||||
3. 发送 `sub2api.plugin.ready`;
|
||||
4. 调用 `config.load` 渲染表单;
|
||||
5. 编辑后调用 `config.save`;
|
||||
6. 测试前先保存,再调用 `config.test`;
|
||||
7. 页面卸载时调用 `dispose()`。
|
||||
|
||||
当前 Bridge 支持:
|
||||
|
||||
| 消息 | 用途 |
|
||||
| --- | --- |
|
||||
| `config.load` | 读取当前配置。 |
|
||||
| `config.save` | 提交配置,由运行时校验、应用并加密保存。 |
|
||||
| `config.test` | 运行已保存配置的诊断。 |
|
||||
| `ui.resize` | 调整配置 iframe 高度。 |
|
||||
| `ui.notify` | 显示成功、错误或提示消息。 |
|
||||
|
||||
每条消息都必须带 `request_id`,并校验 `event.source`、消息来源标识和 Bridge Token。不要依赖 CDN、远程脚本、Cookie 或本地存储。页面需要兼容窄屏和明暗主题,并正确处理加载、保存、测试、超时和未保存状态。
|
||||
|
||||
详细信封格式见 `backend/pkg/pluginapi/docs/ui-bridge.md`。如果后续示例仓库提供可复用的 Bridge SDK,本文会在示例仓库章节补充对应路径和使用方式。
|
||||
|
||||
## 6. 编写包清单
|
||||
|
||||
只维护 `manifest.source.json`,不要手工编辑构建目录中的 `manifest.json`。至少需要填写:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"id": "example.openai.transport",
|
||||
"name": "Example OpenAI Transport",
|
||||
"version": "0.1.0",
|
||||
"requires": {
|
||||
"sub2api": ">=0.1.179 <0.2.0",
|
||||
"recommended_sub2api_version": "0.1.179",
|
||||
"tested_sub2api_versions": ["0.1.179"],
|
||||
"plugin_protocol": 1,
|
||||
"transport_api": 1,
|
||||
"ui_bridge": 1
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "openai.oauth.outbound_transport.v1",
|
||||
"platform": "openai",
|
||||
"account_type": "oauth"
|
||||
}
|
||||
],
|
||||
"runtimes": {},
|
||||
"ui": { "entrypoint": "ui/index.html" },
|
||||
"files": {}
|
||||
}
|
||||
```
|
||||
|
||||
打包器会自动填充目标平台运行时、UI 和运行时文件的 SHA-256。清单中的 `requires.sub2api` 是硬兼容范围;`tested_sub2api_versions` 应只填写真实验证过的版本;`recommended_sub2api_version` 用于管理页面展示。当前宿主仅处理 `openai.oauth.outbound_transport.v1`,声明其他能力不会自动产生新路由。后续增加 Provider 支持时,会在协议、能力清单和宿主路由完成适配后,再补充对应的清单示例。
|
||||
|
||||
## 7. 生成密钥并签名
|
||||
|
||||
生产包应始终签名,宿主默认拒绝未签名包。可以使用插件工程中的密钥生成工具生成一对 Ed25519 密钥;示例仓库发布后会提供标准工具和完整命令:
|
||||
|
||||
```bash
|
||||
go run ./tools/keygen -out build/keys/my-publisher
|
||||
```
|
||||
|
||||
生成的 `my-publisher.private` 只保存在受控的开发机或 CI Secret 中,不能提交到源码仓库、插件包或部署服务器。公钥是 Base64 文本,可以提供给部署者。
|
||||
|
||||
插件工程的 `build.sh` 应调用标准打包器。自定义发布者密钥时必须同时提供 `-signing-key` 和 `-key-id`:
|
||||
|
||||
```bash
|
||||
./build.sh \
|
||||
-signing-key /安全目录/my-publisher.private \
|
||||
-key-id my-publisher-v1 \
|
||||
-output dist/my-openai-plugin.s2plugin
|
||||
```
|
||||
|
||||
签名覆盖最终 `manifest.json` 的精确字节;清单中的文件哈希再覆盖运行时和 UI 文件。签名完成后不要重新格式化 `manifest.json`。
|
||||
|
||||
部署者在 Sub2API 配置文件中追加公钥:
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
allow_unsigned: false
|
||||
trusted_publishers:
|
||||
my-publisher-v1: "BASE64_ED25519_PUBLIC_KEY"
|
||||
```
|
||||
|
||||
`trusted_publishers` 是在宿主内置官方公钥之外追加的信任来源,不能覆盖内置公钥。`signature.json` 中的 `key_id` 必须与配置键完全一致。密钥轮换时先发布包含新公钥的宿主配置或版本,再发布新签名包,最后再停用旧密钥。
|
||||
|
||||
开发阶段如需使用未签名包,只应在隔离的本地环境临时设置 `plugins.allow_unsigned: true`,测试完成后立即恢复为 `false`。
|
||||
|
||||
## 8. 构建、测试和安装
|
||||
|
||||
在插件目录执行:
|
||||
|
||||
```bash
|
||||
go test ./... -count=1
|
||||
node --check ui/assets/bridge-v1.js
|
||||
node --check ui/assets/app.js
|
||||
./build.sh
|
||||
unzip -t dist/*.s2plugin
|
||||
```
|
||||
|
||||
回到 Sub2API 仓库根目录后,再使用真实构建包运行宿主集成测试:
|
||||
|
||||
```bash
|
||||
cd ../..
|
||||
SUB2API_TEST_PLUGIN_PACKAGE=plugins/my-openai-plugin/dist/my-openai-plugin.s2plugin \
|
||||
go test ./backend/internal/service -run '^TestPluginRuntimeIntegration$' -count=1
|
||||
```
|
||||
|
||||
最低测试集应覆盖配置默认值和边界值、插件身份、请求和响应分块、流式响应、上下文取消、插件退出、代理开关、包哈希、签名、路径安全、目标平台运行时以及 UI Bridge 的加载、保存、测试、错误和超时。
|
||||
|
||||
安装后先保持停用,确认清单兼容性、签名和诊断结果,再按账号灰度启用。API Key 账号和未命中灰度的 OAuth 账号继续走 Sub2API 原有路径。
|
||||
|
||||
## 9. 发布前检查清单
|
||||
|
||||
- 插件版本与 `GetInfo` 返回值一致;
|
||||
- `requires.sub2api` 覆盖范围经过验证,没有未经测试的破坏性版本;
|
||||
- `tested_sub2api_versions` 与实际测试记录一致;
|
||||
- 每个支持的平台和架构都有运行时文件;
|
||||
- 生产包存在有效 `signature.json`,公钥已交付部署者;
|
||||
- 包中没有私钥、源映射、测试数据、日志和临时文件;
|
||||
- UI 不依赖外部资源,也不保存宿主会话信息;
|
||||
- 配置切换、请求取消、响应关闭和错误重试语义经过测试;
|
||||
- 发布说明包含升级、停用、回滚和兼容版本信息。
|
||||
|
||||
## 10. 常见问题
|
||||
|
||||
| 现象 | 排查方向 |
|
||||
| --- | --- |
|
||||
| 安装提示签名不受信任 | 检查 `signature.json.key_id`、Base64 公钥和配置键是否完全一致。 |
|
||||
| 插件显示不兼容 | 检查 `requires.sub2api`、`plugin_protocol`、`transport_api` 和 `ui_bridge`。 |
|
||||
| 插件进程无法启动 | 检查目标系统和架构对应的运行时路径、可执行权限和运行用户权限。 |
|
||||
| 配置页无法加载 | 检查 `ui.entrypoint`、UI 文件哈希、Bridge Token 校验和 iframe 消息来源。 |
|
||||
| 保存后配置未生效 | 查看 `ValidateConfig`、`ApplyConfig` 返回的规范化配置和诊断信息。 |
|
||||
| 请求失败后重复执行 | 检查 `ForwardResponseError.request_sent` 是否准确反映请求是否可能已发出。 |
|
||||
|
||||
## 11. 需要扩展能力时
|
||||
|
||||
如果新插件需要支持其他 Provider、其他账号类型或新的消息字段,应先扩展并版本化公开协议,再由宿主增加能力匹配和生命周期处理。不要仅通过清单声明一个宿主尚未实现的能力。这样可以让旧插件继续运行,也能让新宿主明确拒绝不兼容的插件。
|
||||
|
||||
Sub2API 后续会持续补充更多 Provider 的插件适配说明,包括能力标识、请求和响应契约、配置字段、UI Bridge 使用方式、版本兼容要求以及测试清单。本文会随着这些能力的落地继续更新,Provider 专属章节会放在本节之后。
|
||||
|
||||
## 12. 示例仓库预留
|
||||
|
||||
后续计划提供独立的插件示例仓库,用于存放可复用的运行时骨架、UI 组件、打包工具和各 Provider 的最小实现。目前示例仓库尚未准备完成,因此暂不提供地址;正式发布后会在这里补充仓库地址、适用的 Sub2API 版本、示例插件版本和构建说明。
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 282 KiB |
@@ -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'
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export interface PluginCapability {
|
||||
id: string
|
||||
platform: string
|
||||
account_type: string
|
||||
}
|
||||
|
||||
export interface PluginRequirements {
|
||||
sub2api: string
|
||||
recommended_sub2api_version?: string
|
||||
tested_sub2api_versions?: string[]
|
||||
plugin_protocol: number
|
||||
transport_api: number
|
||||
ui_bridge: number
|
||||
}
|
||||
|
||||
export interface PluginManifest {
|
||||
schema_version: number
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
description?: string
|
||||
author?: string
|
||||
requires: PluginRequirements
|
||||
capabilities: PluginCapability[]
|
||||
ui: { entrypoint: string }
|
||||
}
|
||||
|
||||
export interface PluginCompatibility {
|
||||
compatible: boolean
|
||||
tested: boolean
|
||||
status: 'compatible' | 'untested' | 'incompatible'
|
||||
message: string
|
||||
current_sub2api_version: string
|
||||
required_sub2api_version: string
|
||||
recommended_sub2api_version: string
|
||||
plugin_protocol: number
|
||||
transport_api: number
|
||||
ui_bridge: number
|
||||
}
|
||||
|
||||
export interface PluginBinding {
|
||||
id: number
|
||||
plugin_id: number
|
||||
capability: string
|
||||
platform: string
|
||||
account_type: string
|
||||
enabled: boolean
|
||||
rollout_percent: number
|
||||
}
|
||||
|
||||
export interface PluginInstallation {
|
||||
id: number
|
||||
plugin_key: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
author: string
|
||||
manifest: PluginManifest
|
||||
binary_sha256: string
|
||||
signature_status: 'trusted' | 'unsigned'
|
||||
state: 'disabled' | 'starting' | 'enabled' | 'error' | 'incompatible'
|
||||
last_error: string
|
||||
installed_at: string
|
||||
enabled_at?: string
|
||||
updated_at: string
|
||||
bindings: PluginBinding[]
|
||||
compatibility: PluginCompatibility
|
||||
runtime_healthy: boolean
|
||||
runtime_message: string
|
||||
}
|
||||
|
||||
export interface PluginTestResult {
|
||||
success: boolean
|
||||
message: string
|
||||
latency_ms: number
|
||||
}
|
||||
|
||||
export interface PluginUISession {
|
||||
url: string
|
||||
bridge_token: string
|
||||
ui_bridge_version: number
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export async function list(): Promise<PluginInstallation[]> {
|
||||
const { data } = await apiClient.get<PluginInstallation[]>('/admin/plugins')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function upload(file: File): Promise<PluginInstallation> {
|
||||
const form = new FormData()
|
||||
form.append('plugin', file)
|
||||
const { data } = await apiClient.post<PluginInstallation>('/admin/plugins/upload', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 120000
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function enable(
|
||||
id: number,
|
||||
rolloutPercent: number,
|
||||
acceptUntested: boolean
|
||||
): Promise<PluginInstallation> {
|
||||
const { data } = await apiClient.post<PluginInstallation>(`/admin/plugins/${id}/enable`, {
|
||||
rollout_percent: rolloutPercent,
|
||||
accept_untested: acceptUntested
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function disable(id: number): Promise<PluginInstallation> {
|
||||
const { data } = await apiClient.post<PluginInstallation>(`/admin/plugins/${id}/disable`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function remove(id: number): Promise<void> {
|
||||
await apiClient.delete(`/admin/plugins/${id}`)
|
||||
}
|
||||
|
||||
export async function getConfig(id: number): Promise<Record<string, unknown>> {
|
||||
const { data } = await apiClient.get<Record<string, unknown>>(`/admin/plugins/${id}/config`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function saveConfig(
|
||||
id: number,
|
||||
config: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
const { data } = await apiClient.put<Record<string, unknown>>(`/admin/plugins/${id}/config`, config)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function test(id: number): Promise<PluginTestResult> {
|
||||
const { data } = await apiClient.post<PluginTestResult>(`/admin/plugins/${id}/test`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createUISession(id: number): Promise<PluginUISession> {
|
||||
const { data } = await apiClient.post<PluginUISession>(`/admin/plugins/${id}/ui-session`)
|
||||
return data
|
||||
}
|
||||
|
||||
export default {
|
||||
list,
|
||||
upload,
|
||||
enable,
|
||||
disable,
|
||||
remove,
|
||||
getConfig,
|
||||
saveConfig,
|
||||
test,
|
||||
createUISession
|
||||
}
|
||||
@@ -727,6 +727,7 @@ export interface SystemSettings {
|
||||
model_plaza_enabled: boolean;
|
||||
model_plaza_require_auth: boolean;
|
||||
model_plaza_description: string;
|
||||
plugin_management_enabled: boolean;
|
||||
|
||||
// Affiliate (邀请返利) feature switch
|
||||
affiliate_enabled: boolean;
|
||||
@@ -1026,6 +1027,7 @@ export interface UpdateSettingsRequest {
|
||||
model_plaza_enabled?: boolean;
|
||||
model_plaza_require_auth?: boolean;
|
||||
model_plaza_description?: string;
|
||||
plugin_management_enabled?: boolean;
|
||||
|
||||
// Affiliate (邀请返利) feature switch
|
||||
affiliate_enabled?: boolean;
|
||||
|
||||
@@ -193,6 +193,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
||||
import VersionBadge from '@/components/common/VersionBadge.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { sanitizeSvg } from '@/utils/sanitize'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
|
||||
@@ -472,6 +473,10 @@ const ServerIcon = {
|
||||
)
|
||||
}
|
||||
|
||||
const PluginIcon = {
|
||||
render: () => h(Icon, { name: 'cube' })
|
||||
}
|
||||
|
||||
const BellIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
@@ -685,6 +690,7 @@ const flagPayment = makeSidebarFlag(FeatureFlags.payment)
|
||||
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
|
||||
const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
|
||||
const flagRiskControl = makeSidebarFlag(FeatureFlags.riskControl)
|
||||
const flagPluginManagement = makeSidebarFlag(FeatureFlags.pluginManagement)
|
||||
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
|
||||
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
|
||||
const flagBatchImageAccess = () => canUseBatchImage.value
|
||||
@@ -769,6 +775,7 @@ const adminNavItems = computed((): NavItem[] => {
|
||||
},
|
||||
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
|
||||
{ path: '/admin/plugins', label: t('nav.plugins'), icon: PluginIcon, featureFlag: flagPluginManagement },
|
||||
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
|
||||
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import ops from './ops'
|
||||
import settings from './settings'
|
||||
import audit from './audit'
|
||||
import promptAudit from './promptAudit'
|
||||
import plugins from './plugins'
|
||||
|
||||
export default {
|
||||
...overview,
|
||||
@@ -16,4 +17,5 @@ export default {
|
||||
...settings,
|
||||
...audit,
|
||||
...promptAudit,
|
||||
...plugins,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export default {
|
||||
plugins: {
|
||||
title: 'Plugin Management',
|
||||
description: 'Install and manage isolated OAuth outbound transport plugins. API Key flows are unchanged.',
|
||||
upload: 'Install plugin',
|
||||
uploadHint: 'Only .s2plugin packages are accepted; trusted publisher signatures are required by default.',
|
||||
runtimeNotice: 'Plugin installation, enable/disable, and configuration are handled dynamically by the Sub2API host and normally do not require a host restart. Restart only when the host version or host configuration changes according to your deployment process.',
|
||||
menuNotice: 'The Plugin Management switch in System Settings controls only sidebar visibility; it does not stop loaded or running plugins.',
|
||||
empty: 'No plugins installed',
|
||||
emptyHint: 'Select a local .s2plugin package. Sub2API never downloads third-party plugins automatically.',
|
||||
configure: 'Configure',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
test: 'Test',
|
||||
uninstall: 'Uninstall',
|
||||
rollout: 'OAuth traffic percentage',
|
||||
compatibility: 'Version compatibility',
|
||||
currentVersion: 'Current Sub2API',
|
||||
requiredVersion: 'Required range',
|
||||
recommendedVersion: 'Recommended version',
|
||||
signature: 'Package signature',
|
||||
trusted: 'Verified',
|
||||
unsigned: 'Unsigned',
|
||||
runtime: 'Runtime',
|
||||
healthy: 'Healthy',
|
||||
unhealthy: 'Not running',
|
||||
compatible: 'Compatible',
|
||||
untested: 'Untested version',
|
||||
incompatible: 'Incompatible',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
error: 'Error',
|
||||
starting: 'Starting',
|
||||
configTitle: '{name} configuration',
|
||||
loadingUI: 'Loading plugin configuration UI...',
|
||||
uiUnavailable: 'Unable to load the plugin configuration UI',
|
||||
uploadSuccess: 'Plugin installed and kept disabled',
|
||||
enableSuccess: 'Plugin enabled',
|
||||
disableSuccess: 'Plugin disabled',
|
||||
uninstallSuccess: 'Plugin uninstalled',
|
||||
testSuccess: 'Plugin test passed',
|
||||
confirmDisable: 'Disable this plugin? New OAuth requests immediately return to the built-in Sub2API path.',
|
||||
confirmUninstall: 'Uninstall this plugin? It must be disabled first. Installed files and configuration will be removed.',
|
||||
confirmUntested: 'This plugin is compatible but has not declared the current Sub2API version as tested. Enable it anyway?',
|
||||
fileRequired: 'Select a .s2plugin file',
|
||||
bridgeRejected: 'Plugin UI message validation failed',
|
||||
onlyOpenAI: 'Initial capability: OpenAI OAuth outbound transport only',
|
||||
noAccountCoupling: 'The scope is platform and account type. Account records are not changed and no per-account toggle is required.'
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,12 @@ export default {
|
||||
priceDescription: 'Pricing notes (Markdown)',
|
||||
priceDescriptionHint: 'Rendered at the top of the plaza page. Use it for billing rules, exchange rates, promotions, etc.',
|
||||
},
|
||||
pluginManagement: {
|
||||
title: 'Plugin Management',
|
||||
description: 'Controls whether the plugin management entry appears in the admin sidebar. This switch does not control plugin runtime state.',
|
||||
enabled: 'Show Plugin Management',
|
||||
enabledHint: 'Turning this off only hides the sidebar entry; loaded or running plugins are not stopped.',
|
||||
},
|
||||
riskControl: {
|
||||
title: 'Risk Control',
|
||||
description: 'Enable the content moderation menu and gateway audit entry point. Disabled by default.',
|
||||
|
||||
@@ -176,6 +176,7 @@ export default {
|
||||
modelPlaza: 'Model Plaza',
|
||||
subscriptions: 'Subscriptions',
|
||||
accounts: 'Accounts',
|
||||
plugins: 'Plugins',
|
||||
proxies: 'Proxies',
|
||||
redeemCodes: 'Redeem Codes',
|
||||
ops: 'Ops',
|
||||
|
||||
@@ -6,6 +6,7 @@ import ops from './ops'
|
||||
import settings from './settings'
|
||||
import audit from './audit'
|
||||
import promptAudit from './promptAudit'
|
||||
import plugins from './plugins'
|
||||
|
||||
export default {
|
||||
...overview,
|
||||
@@ -16,4 +17,5 @@ export default {
|
||||
...settings,
|
||||
...audit,
|
||||
...promptAudit,
|
||||
...plugins,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export default {
|
||||
plugins: {
|
||||
title: '插件管理',
|
||||
description: '安装和管理独立运行的 OAuth 出站传输插件。API Key 流程不受影响。',
|
||||
upload: '安装插件',
|
||||
uploadHint: '仅接受 .s2plugin 包;默认要求可信发布者签名。',
|
||||
runtimeNotice: '插件安装、启用、停用和配置由 Sub2API 宿主动态处理,通常不需要重启宿主实例。只有宿主版本或宿主配置本身变化时,才按部署方式执行重启。',
|
||||
menuNotice: '系统设置中的“插件管理”开关仅控制侧边栏菜单显示,不会停止已经加载或正在运行的插件。',
|
||||
empty: '尚未安装插件',
|
||||
emptyHint: '选择本机的 .s2plugin 文件进行安装。Sub2API 不会自动下载第三方插件。',
|
||||
configure: '配置',
|
||||
enable: '启用',
|
||||
disable: '停用',
|
||||
test: '测试',
|
||||
uninstall: '卸载',
|
||||
rollout: 'OAuth 流量比例',
|
||||
compatibility: '版本兼容性',
|
||||
currentVersion: '当前 Sub2API',
|
||||
requiredVersion: '要求范围',
|
||||
recommendedVersion: '建议版本',
|
||||
signature: '包签名',
|
||||
trusted: '已验证',
|
||||
unsigned: '未签名',
|
||||
runtime: '运行状态',
|
||||
healthy: '运行正常',
|
||||
unhealthy: '未运行',
|
||||
compatible: '兼容',
|
||||
untested: '未验证版本',
|
||||
incompatible: '不兼容',
|
||||
enabled: '已启用',
|
||||
disabled: '已停用',
|
||||
error: '异常',
|
||||
starting: '启动中',
|
||||
configTitle: '{name} 配置',
|
||||
loadingUI: '正在加载插件配置界面...',
|
||||
uiUnavailable: '无法加载插件配置界面',
|
||||
uploadSuccess: '插件安装成功,当前保持停用',
|
||||
enableSuccess: '插件已启用',
|
||||
disableSuccess: '插件已停用',
|
||||
uninstallSuccess: '插件已卸载',
|
||||
testSuccess: '插件测试通过',
|
||||
confirmDisable: '确定停用此插件吗?新的 OAuth 请求会立即恢复 Sub2API 原有路径。',
|
||||
confirmUninstall: '确定卸载此插件吗?插件必须先停用。此操作会移除安装文件和配置。',
|
||||
confirmUntested: '该插件兼容当前版本范围,但未声明已测试当前 Sub2API 版本。确定承担风险并启用吗?',
|
||||
fileRequired: '请选择 .s2plugin 文件',
|
||||
bridgeRejected: '插件 UI 消息校验失败',
|
||||
onlyOpenAI: '初期能力:仅 OpenAI OAuth 出站传输',
|
||||
noAccountCoupling: '作用域为平台与账号类型,不修改账号数据,也不需要在账号页逐个开启。'
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,12 @@ export default {
|
||||
priceDescription: '价格说明(Markdown)',
|
||||
priceDescriptionHint: '展示在模型广场页面顶部,可用于说明计费规则、汇率、优惠活动等。',
|
||||
},
|
||||
pluginManagement: {
|
||||
title: '插件管理',
|
||||
description: '控制管理员侧边栏是否显示插件管理入口。此开关不控制插件运行状态。',
|
||||
enabled: '显示插件管理菜单',
|
||||
enabledHint: '关闭后仅隐藏侧边栏菜单;已加载或正在运行的插件不会因此停止。',
|
||||
},
|
||||
riskControl: {
|
||||
title: '风控中心',
|
||||
description: '启用内容审计菜单和全端点请求审核入口。默认关闭。',
|
||||
|
||||
@@ -176,6 +176,7 @@ export default {
|
||||
modelPlaza: '模型广场',
|
||||
subscriptions: '订阅管理',
|
||||
accounts: '账号管理',
|
||||
plugins: '插件管理',
|
||||
proxies: 'IP管理',
|
||||
redeemCodes: '兑换码',
|
||||
ops: '运维监控',
|
||||
|
||||
@@ -524,6 +524,18 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'admin.accounts.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/plugins',
|
||||
name: 'AdminPlugins',
|
||||
component: () => import('@/views/admin/PluginsView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Plugin Management',
|
||||
titleKey: 'admin.plugins.title',
|
||||
descriptionKey: 'admin.plugins.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/announcements',
|
||||
name: 'AdminAnnouncements',
|
||||
|
||||
@@ -57,6 +57,7 @@ function createPublicSettings(overrides: Partial<PublicSettings> = {}): PublicSe
|
||||
available_channels_enabled: false,
|
||||
model_plaza_enabled: false,
|
||||
model_plaza_require_auth: false,
|
||||
plugin_management_enabled: false,
|
||||
service_quota_enabled: false,
|
||||
affiliate_enabled: false,
|
||||
...overrides,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<section
|
||||
class="flex flex-col gap-4 border-b border-gray-200 pb-5 dark:border-dark-700 sm:flex-row sm:items-end sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.plugins.title") }}
|
||||
</h2>
|
||||
<p class="mt-1 max-w-3xl text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.plugins.description") }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-3 flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
<span class="rounded bg-gray-100 px-2 py-1 dark:bg-dark-700">{{
|
||||
t("admin.plugins.onlyOpenAI")
|
||||
}}</span>
|
||||
<span class="rounded bg-gray-100 px-2 py-1 dark:bg-dark-700">{{
|
||||
t("admin.plugins.noAccountCoupling")
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-shrink-0 items-center gap-2">
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept=".s2plugin,application/zip"
|
||||
@change="handleFileSelected"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="uploading"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<Icon name="upload" size="sm" />
|
||||
{{ uploading ? t("common.processing") : t("admin.plugins.upload") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
:disabled="loading"
|
||||
:title="t('common.refresh')"
|
||||
@click="loadPlugins"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
<span class="sr-only">{{ t("common.refresh") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.plugins.uploadHint") }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200"
|
||||
>
|
||||
<p>{{ t("admin.plugins.runtimeNotice") }}</p>
|
||||
<p class="mt-1">{{ t("admin.plugins.menuNotice") }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex min-h-48 items-center justify-center text-sm text-gray-500"
|
||||
>
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="plugins.length === 0"
|
||||
class="flex min-h-56 flex-col items-center justify-center border border-dashed border-gray-300 px-6 text-center dark:border-dark-600"
|
||||
>
|
||||
<Icon name="cube" size="xl" class="text-gray-400" />
|
||||
<p class="mt-3 font-medium text-gray-800 dark:text-gray-200">
|
||||
{{ t("admin.plugins.empty") }}
|
||||
</p>
|
||||
<p class="mt-1 max-w-lg text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.plugins.emptyHint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<article
|
||||
v-for="plugin in plugins"
|
||||
:key="plugin.id"
|
||||
class="card overflow-hidden border border-gray-200 dark:border-dark-700"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-start justify-between gap-3 border-b border-gray-100 p-5 dark:border-dark-700"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3
|
||||
class="truncate text-base font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
{{ plugin.name }}
|
||||
</h3>
|
||||
<span class="font-mono text-xs text-gray-500"
|
||||
>v{{ plugin.version }}</span
|
||||
>
|
||||
<span
|
||||
class="rounded px-2 py-0.5 text-xs font-medium"
|
||||
:class="stateClass(plugin.state)"
|
||||
>
|
||||
{{ t(`admin.plugins.${plugin.state}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
{{ plugin.plugin_key
|
||||
}}<span v-if="plugin.author"> · {{ plugin.author }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="plugin.description"
|
||||
class="mt-2 text-sm text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{{ plugin.description }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="openConfiguration(plugin)"
|
||||
>
|
||||
<Icon name="cog" size="sm" />
|
||||
{{ t("admin.plugins.configure") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-x-6 gap-y-4 p-5 md:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-gray-500">
|
||||
{{ t("admin.plugins.compatibility") }}
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span
|
||||
class="rounded px-2 py-0.5 text-xs font-medium"
|
||||
:class="compatibilityClass(plugin.compatibility.status)"
|
||||
>
|
||||
{{ t(`admin.plugins.${plugin.compatibility.status}`) }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
plugin.compatibility.message
|
||||
}}</span>
|
||||
</div>
|
||||
<dl
|
||||
class="mt-3 grid grid-cols-[auto,1fr] gap-x-3 gap-y-1 text-xs"
|
||||
>
|
||||
<dt class="text-gray-500">
|
||||
{{ t("admin.plugins.currentVersion") }}
|
||||
</dt>
|
||||
<dd class="font-mono text-gray-800 dark:text-gray-200">
|
||||
{{ plugin.compatibility.current_sub2api_version }}
|
||||
</dd>
|
||||
<dt class="text-gray-500">
|
||||
{{ t("admin.plugins.requiredVersion") }}
|
||||
</dt>
|
||||
<dd class="font-mono text-gray-800 dark:text-gray-200">
|
||||
{{ plugin.compatibility.required_sub2api_version }}
|
||||
</dd>
|
||||
<dt class="text-gray-500">
|
||||
{{ t("admin.plugins.recommendedVersion") }}
|
||||
</dt>
|
||||
<dd class="font-mono text-gray-800 dark:text-gray-200">
|
||||
{{ plugin.compatibility.recommended_sub2api_version || "-" }}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-gray-500">
|
||||
{{ t("admin.plugins.runtime") }}
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
<span
|
||||
class="rounded px-2 py-0.5"
|
||||
:class="
|
||||
plugin.runtime_healthy
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'
|
||||
"
|
||||
>
|
||||
{{
|
||||
plugin.runtime_healthy
|
||||
? t("admin.plugins.healthy")
|
||||
: t("admin.plugins.unhealthy")
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
class="rounded bg-gray-100 px-2 py-0.5 text-gray-600 dark:bg-dark-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.plugins.signature") }}:
|
||||
{{ t(`admin.plugins.${plugin.signature_status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="plugin.last_error"
|
||||
class="mt-3 break-words text-xs text-red-600 dark:text-red-400"
|
||||
>
|
||||
{{ plugin.last_error }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="plugin.runtime_message"
|
||||
class="mt-3 break-words text-xs text-gray-500"
|
||||
>
|
||||
{{ plugin.runtime_message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label
|
||||
class="flex items-center justify-between gap-4 text-xs font-medium text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
<span>{{ t("admin.plugins.rollout") }}</span>
|
||||
<span class="w-11 text-right font-mono"
|
||||
>{{
|
||||
rolloutValues[plugin.id] ?? currentRollout(plugin)
|
||||
}}%</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
:value="rolloutValues[plugin.id] ?? currentRollout(plugin)"
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
step="1"
|
||||
class="mt-2 w-full accent-primary-600"
|
||||
:disabled="hasEnabledBinding(plugin)"
|
||||
@input="setRollout(plugin.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap justify-end gap-2 border-t border-gray-100 px-5 py-4 dark:border-dark-700"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="busyID === plugin.id"
|
||||
@click="testPlugin(plugin)"
|
||||
>
|
||||
<Icon name="beaker" size="sm" />
|
||||
{{ t("admin.plugins.test") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="hasEnabledBinding(plugin)"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="busyID === plugin.id"
|
||||
@click="disablePlugin(plugin)"
|
||||
>
|
||||
<Icon name="ban" size="sm" />
|
||||
{{ t("admin.plugins.disable") }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="
|
||||
busyID === plugin.id ||
|
||||
plugin.state === 'starting' ||
|
||||
!plugin.compatibility.compatible
|
||||
"
|
||||
@click="enablePlugin(plugin)"
|
||||
>
|
||||
<Icon name="play" size="sm" />
|
||||
{{ t("admin.plugins.enable") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger btn-sm"
|
||||
:disabled="busyID === plugin.id || hasEnabledBinding(plugin)"
|
||||
@click="uninstallPlugin(plugin)"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
{{ t("admin.plugins.uninstall") }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<BaseDialog
|
||||
:show="configPlugin !== null"
|
||||
:title="
|
||||
t('admin.plugins.configTitle', { name: configPlugin?.name || '' })
|
||||
"
|
||||
width="full"
|
||||
@close="closeConfiguration"
|
||||
>
|
||||
<div
|
||||
class="relative min-h-[520px] overflow-hidden bg-gray-50 dark:bg-dark-900"
|
||||
:style="{ height: `${iframeHeight}px` }"
|
||||
>
|
||||
<div
|
||||
v-if="uiLoading"
|
||||
class="absolute inset-0 z-10 flex items-center justify-center text-sm text-gray-500"
|
||||
>
|
||||
{{ t("admin.plugins.loadingUI") }}
|
||||
</div>
|
||||
<div
|
||||
v-if="uiError"
|
||||
class="absolute inset-0 z-20 flex flex-col items-center justify-center p-8 text-center"
|
||||
>
|
||||
<Icon name="exclamationTriangle" size="xl" class="text-amber-500" />
|
||||
<p class="mt-3 font-medium text-gray-800 dark:text-gray-200">
|
||||
{{ t("admin.plugins.uiUnavailable") }}
|
||||
</p>
|
||||
<p class="mt-1 max-w-xl text-sm text-gray-500">{{ uiError }}</p>
|
||||
</div>
|
||||
<iframe
|
||||
v-if="uiSession"
|
||||
ref="pluginFrame"
|
||||
:src="uiSession.url"
|
||||
sandbox="allow-scripts"
|
||||
referrerpolicy="no-referrer"
|
||||
class="h-full w-full border-0 bg-white dark:bg-dark-900"
|
||||
:title="
|
||||
t('admin.plugins.configTitle', { name: configPlugin?.name || '' })
|
||||
"
|
||||
@load="handlePluginFrameLoad"
|
||||
/>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<TotpStepUpDialog :controller="pluginStepUp" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
adminAPI,
|
||||
type PluginInstallation,
|
||||
type PluginUISession,
|
||||
} from "@/api/admin";
|
||||
import { useAppStore } from "@/stores";
|
||||
import AppLayout from "@/components/layout/AppLayout.vue";
|
||||
import BaseDialog from "@/components/common/BaseDialog.vue";
|
||||
import Icon from "@/components/icons/Icon.vue";
|
||||
import TotpStepUpDialog from "@/components/auth/TotpStepUpDialog.vue";
|
||||
import {
|
||||
isStepUpBlocked,
|
||||
isStepUpCancelled,
|
||||
stepUpBlockReason,
|
||||
useStepUp,
|
||||
} from "@/composables/useStepUp";
|
||||
|
||||
interface PluginBridgeMessage {
|
||||
source?: string;
|
||||
bridge_token?: string;
|
||||
type?: string;
|
||||
request_id?: string;
|
||||
config?: unknown;
|
||||
height?: unknown;
|
||||
level?: unknown;
|
||||
message?: unknown;
|
||||
}
|
||||
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
const pluginStepUp = useStepUp();
|
||||
const plugins = ref<PluginInstallation[]>([]);
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const busyID = ref<number | null>(null);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const rolloutValues = ref<Record<number, number>>({});
|
||||
const configPlugin = ref<PluginInstallation | null>(null);
|
||||
const uiSession = ref<PluginUISession | null>(null);
|
||||
const pluginFrame = ref<HTMLIFrameElement | null>(null);
|
||||
const uiLoading = ref(false);
|
||||
const uiError = ref("");
|
||||
const iframeHeight = ref(640);
|
||||
const pluginFrameLoaded = ref(false);
|
||||
const pendingBridgeRequests = new Map<string, number>();
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (typeof error === "object" && error !== null && "message" in error) {
|
||||
return String(
|
||||
(error as { message?: unknown }).message || t("common.unknownError"),
|
||||
);
|
||||
}
|
||||
return t("common.unknownError");
|
||||
}
|
||||
|
||||
function reportSensitiveActionError(error: unknown): void {
|
||||
if (isStepUpCancelled(error)) return;
|
||||
if (isStepUpBlocked(error)) {
|
||||
appStore.showError(
|
||||
stepUpBlockReason(error) === "STEP_UP_ADMIN_API_KEY_FORBIDDEN"
|
||||
? t("stepUp.adminApiKeyForbidden")
|
||||
: t("stepUp.notEnabled"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
appStore.showError(errorMessage(error));
|
||||
}
|
||||
|
||||
async function loadPlugins(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
plugins.value = await adminAPI.plugins.list();
|
||||
for (const plugin of plugins.value) {
|
||||
rolloutValues.value[plugin.id] = currentRollout(plugin);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
appStore.showError(errorMessage(error));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileSelected(event: Event): Promise<void> {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
target.value = "";
|
||||
if (!file || !file.name.toLowerCase().endsWith(".s2plugin")) {
|
||||
appStore.showError(t("admin.plugins.fileRequired"));
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
try {
|
||||
await pluginStepUp.run(() => adminAPI.plugins.upload(file));
|
||||
appStore.showSuccess(t("admin.plugins.uploadSuccess"));
|
||||
await loadPlugins();
|
||||
} catch (error: unknown) {
|
||||
reportSensitiveActionError(error);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function currentRollout(plugin: PluginInstallation): number {
|
||||
return (
|
||||
plugin.bindings.find(
|
||||
(binding) => binding.capability === "openai.oauth.outbound_transport.v1",
|
||||
)?.rollout_percent || 100
|
||||
);
|
||||
}
|
||||
|
||||
function hasEnabledBinding(plugin: PluginInstallation): boolean {
|
||||
return plugin.bindings.some((binding) => binding.enabled);
|
||||
}
|
||||
|
||||
function setRollout(id: number, event: Event): void {
|
||||
const value = Number((event.target as HTMLInputElement).value);
|
||||
rolloutValues.value[id] = Math.min(100, Math.max(1, value));
|
||||
}
|
||||
|
||||
async function enablePlugin(plugin: PluginInstallation): Promise<void> {
|
||||
let acceptUntested = false;
|
||||
if (!plugin.compatibility.tested) {
|
||||
acceptUntested = window.confirm(t("admin.plugins.confirmUntested"));
|
||||
if (!acceptUntested) return;
|
||||
}
|
||||
busyID.value = plugin.id;
|
||||
try {
|
||||
await pluginStepUp.run(() =>
|
||||
adminAPI.plugins.enable(
|
||||
plugin.id,
|
||||
rolloutValues.value[plugin.id] || 100,
|
||||
acceptUntested,
|
||||
),
|
||||
);
|
||||
appStore.showSuccess(t("admin.plugins.enableSuccess"));
|
||||
await loadPlugins();
|
||||
} catch (error: unknown) {
|
||||
reportSensitiveActionError(error);
|
||||
} finally {
|
||||
busyID.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function disablePlugin(plugin: PluginInstallation): Promise<void> {
|
||||
if (!window.confirm(t("admin.plugins.confirmDisable"))) return;
|
||||
busyID.value = plugin.id;
|
||||
try {
|
||||
await pluginStepUp.run(() => adminAPI.plugins.disable(plugin.id));
|
||||
appStore.showSuccess(t("admin.plugins.disableSuccess"));
|
||||
await loadPlugins();
|
||||
} catch (error: unknown) {
|
||||
reportSensitiveActionError(error);
|
||||
} finally {
|
||||
busyID.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallPlugin(plugin: PluginInstallation): Promise<void> {
|
||||
if (!window.confirm(t("admin.plugins.confirmUninstall"))) return;
|
||||
busyID.value = plugin.id;
|
||||
try {
|
||||
await pluginStepUp.run(() => adminAPI.plugins.remove(plugin.id));
|
||||
appStore.showSuccess(t("admin.plugins.uninstallSuccess"));
|
||||
await loadPlugins();
|
||||
} catch (error: unknown) {
|
||||
reportSensitiveActionError(error);
|
||||
} finally {
|
||||
busyID.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function testPlugin(plugin: PluginInstallation): Promise<void> {
|
||||
busyID.value = plugin.id;
|
||||
try {
|
||||
const result = await pluginStepUp.run(() =>
|
||||
adminAPI.plugins.test(plugin.id),
|
||||
);
|
||||
if (result.success)
|
||||
appStore.showSuccess(result.message || t("admin.plugins.testSuccess"));
|
||||
else appStore.showError(result.message || t("common.error"));
|
||||
} catch (error: unknown) {
|
||||
reportSensitiveActionError(error);
|
||||
} finally {
|
||||
busyID.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function openConfiguration(plugin: PluginInstallation): Promise<void> {
|
||||
configPlugin.value = plugin;
|
||||
uiSession.value = null;
|
||||
pluginFrameLoaded.value = false;
|
||||
clearPendingBridgeRequests();
|
||||
uiLoading.value = true;
|
||||
uiError.value = "";
|
||||
iframeHeight.value = 640;
|
||||
try {
|
||||
uiSession.value = await adminAPI.plugins.createUISession(plugin.id);
|
||||
} catch (error: unknown) {
|
||||
uiLoading.value = false;
|
||||
uiError.value = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
function closeConfiguration(): void {
|
||||
clearPendingBridgeRequests();
|
||||
pluginFrameLoaded.value = false;
|
||||
configPlugin.value = null;
|
||||
uiSession.value = null;
|
||||
uiLoading.value = false;
|
||||
uiError.value = "";
|
||||
}
|
||||
|
||||
function clearPendingBridgeRequests(): void {
|
||||
for (const timeout of pendingBridgeRequests.values()) window.clearTimeout(timeout);
|
||||
pendingBridgeRequests.clear();
|
||||
}
|
||||
|
||||
function handlePluginFrameLoad(): void {
|
||||
// A load can also be caused by a plugin navigating its iframe. Drop all
|
||||
// outstanding responses so a late config response is never sent to the new document.
|
||||
if (pluginFrameLoaded.value) clearPendingBridgeRequests();
|
||||
pluginFrameLoaded.value = true;
|
||||
uiLoading.value = false;
|
||||
}
|
||||
|
||||
function registerBridgeRequest(requestID: string): void {
|
||||
const timeout = window.setTimeout(() => {
|
||||
pendingBridgeRequests.delete(requestID);
|
||||
}, 30_000);
|
||||
pendingBridgeRequests.set(requestID, timeout);
|
||||
}
|
||||
|
||||
function postBridgeResult(
|
||||
request: PluginBridgeMessage,
|
||||
payload: Record<string, unknown>,
|
||||
): void {
|
||||
if (!pluginFrame.value?.contentWindow || !uiSession.value) return;
|
||||
const requestID = typeof request.request_id === "string" ? request.request_id.trim() : "";
|
||||
const timeout = pendingBridgeRequests.get(requestID);
|
||||
if (!requestID || timeout === undefined) return;
|
||||
window.clearTimeout(timeout);
|
||||
pendingBridgeRequests.delete(requestID);
|
||||
pluginFrame.value.contentWindow.postMessage(
|
||||
{
|
||||
source: "sub2api-plugin-host",
|
||||
bridge_token: uiSession.value.bridge_token,
|
||||
type: `${request.type}.result`,
|
||||
request_id: requestID,
|
||||
...payload,
|
||||
},
|
||||
// The sandboxed iframe has an opaque origin, so no fixed target origin exists.
|
||||
// Pending request tracking plus load invalidation prevents cross-navigation leaks.
|
||||
"*",
|
||||
);
|
||||
}
|
||||
|
||||
async function handleBridgeMessage(event: MessageEvent): Promise<void> {
|
||||
if (
|
||||
!uiSession.value ||
|
||||
!configPlugin.value ||
|
||||
event.source !== pluginFrame.value?.contentWindow ||
|
||||
event.origin !== "null"
|
||||
)
|
||||
return;
|
||||
const message = event.data as PluginBridgeMessage;
|
||||
if (
|
||||
!message ||
|
||||
message.source !== "sub2api-plugin-ui" ||
|
||||
message.bridge_token !== uiSession.value.bridge_token
|
||||
)
|
||||
return;
|
||||
|
||||
const requestID = typeof message.request_id === "string" ? message.request_id.trim() : "";
|
||||
const expectsResponse =
|
||||
message.type === "config.load" ||
|
||||
message.type === "config.save" ||
|
||||
message.type === "config.test";
|
||||
if (expectsResponse) {
|
||||
if (!requestID || pendingBridgeRequests.has(requestID)) return;
|
||||
registerBridgeRequest(requestID);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
case "sub2api.plugin.ready":
|
||||
uiLoading.value = false;
|
||||
break;
|
||||
case "config.load": {
|
||||
const config = await adminAPI.plugins.getConfig(configPlugin.value.id);
|
||||
postBridgeResult(message, { ok: true, config });
|
||||
break;
|
||||
}
|
||||
case "config.save": {
|
||||
if (
|
||||
!message.config ||
|
||||
typeof message.config !== "object" ||
|
||||
Array.isArray(message.config)
|
||||
) {
|
||||
throw new Error(t("admin.plugins.bridgeRejected"));
|
||||
}
|
||||
const config = await pluginStepUp.run(() =>
|
||||
adminAPI.plugins.saveConfig(
|
||||
configPlugin.value!.id,
|
||||
message.config as Record<string, unknown>,
|
||||
),
|
||||
);
|
||||
postBridgeResult(message, { ok: true, config });
|
||||
appStore.showSuccess(t("common.saved"));
|
||||
break;
|
||||
}
|
||||
case "config.test": {
|
||||
const result = await pluginStepUp.run(() =>
|
||||
adminAPI.plugins.test(configPlugin.value!.id),
|
||||
);
|
||||
postBridgeResult(message, { ok: result.success, result });
|
||||
if (result.success)
|
||||
appStore.showSuccess(
|
||||
result.message || t("admin.plugins.testSuccess"),
|
||||
);
|
||||
else appStore.showError(result.message || t("common.error"));
|
||||
break;
|
||||
}
|
||||
case "ui.resize": {
|
||||
const height = Number(message.height);
|
||||
if (Number.isFinite(height))
|
||||
iframeHeight.value = Math.min(960, Math.max(520, Math.round(height)));
|
||||
break;
|
||||
}
|
||||
case "ui.notify": {
|
||||
const text =
|
||||
typeof message.message === "string"
|
||||
? message.message.slice(0, 500)
|
||||
: "";
|
||||
if (!text) break;
|
||||
if (message.level === "error") appStore.showError(text);
|
||||
else if (message.level === "success") appStore.showSuccess(text);
|
||||
else appStore.showInfo(text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isStepUpBlocked(error)) reportSensitiveActionError(error);
|
||||
postBridgeResult(message, {
|
||||
ok: false,
|
||||
error: isStepUpCancelled(error) ? t("common.cancel") : errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stateClass(state: PluginInstallation["state"]): string {
|
||||
if (state === "enabled")
|
||||
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
|
||||
if (state === "error" || state === "incompatible")
|
||||
return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300";
|
||||
if (state === "starting")
|
||||
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
|
||||
return "bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300";
|
||||
}
|
||||
|
||||
function compatibilityClass(
|
||||
status: PluginInstallation["compatibility"]["status"],
|
||||
): string {
|
||||
if (status === "compatible")
|
||||
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
|
||||
if (status === "untested")
|
||||
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
|
||||
return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("message", handleBridgeMessage);
|
||||
void loadPlugins();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("message", handleBridgeMessage);
|
||||
clearPendingBridgeRequests();
|
||||
});
|
||||
</script>
|
||||
@@ -7188,6 +7188,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.features.pluginManagement.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.features.pluginManagement.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-5 p-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.features.pluginManagement.enabled') }}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.features.pluginManagement.enabledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.plugin_management_enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
@@ -9752,6 +9776,8 @@ const form = reactive<SettingsForm>({
|
||||
model_plaza_enabled: false,
|
||||
model_plaza_require_auth: false,
|
||||
model_plaza_description: '',
|
||||
// Plugin management menu visibility; plugin runtime is unaffected.
|
||||
plugin_management_enabled: false,
|
||||
// Affiliate (邀请返利) feature switch
|
||||
affiliate_enabled: false,
|
||||
// Allow user view error requests
|
||||
@@ -11408,6 +11434,7 @@ async function saveSettings() {
|
||||
model_plaza_enabled: form.model_plaza_enabled,
|
||||
model_plaza_require_auth: form.model_plaza_require_auth,
|
||||
model_plaza_description: form.model_plaza_description,
|
||||
plugin_management_enabled: form.plugin_management_enabled,
|
||||
// Affiliate (邀请返利) feature switch
|
||||
affiliate_enabled: form.affiliate_enabled,
|
||||
allow_user_view_error_requests: form.allow_user_view_error_requests,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PluginsView from '../PluginsView.vue'
|
||||
|
||||
const {
|
||||
listPlugins,
|
||||
uploadPlugin,
|
||||
enablePlugin,
|
||||
savePluginConfig,
|
||||
createUISession,
|
||||
stepUpRun,
|
||||
} = vi.hoisted(() => ({
|
||||
listPlugins: vi.fn(),
|
||||
uploadPlugin: vi.fn(),
|
||||
enablePlugin: vi.fn(),
|
||||
savePluginConfig: vi.fn(),
|
||||
createUISession: vi.fn(),
|
||||
stepUpRun: vi.fn((action: () => Promise<unknown>) => action()),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
plugins: {
|
||||
list: listPlugins,
|
||||
upload: uploadPlugin,
|
||||
enable: enablePlugin,
|
||||
disable: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
getConfig: vi.fn().mockResolvedValue({}),
|
||||
saveConfig: savePluginConfig,
|
||||
test: vi.fn().mockResolvedValue({ success: true, message: 'ok', latency_ms: 1 }),
|
||||
createUISession,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showInfo: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useStepUp', () => ({
|
||||
useStepUp: () => ({ run: stepUpRun }),
|
||||
isStepUpBlocked: () => false,
|
||||
isStepUpCancelled: () => false,
|
||||
stepUpBlockReason: () => '',
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('vue-i18n')>()),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
const plugin = {
|
||||
id: 7,
|
||||
plugin_key: 'local.test.transport',
|
||||
name: 'Test Transport',
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
author: 'test',
|
||||
manifest: {
|
||||
schema_version: 1,
|
||||
id: 'local.test.transport',
|
||||
name: 'Test Transport',
|
||||
version: '1.0.0',
|
||||
requires: {
|
||||
sub2api: '>=0.1.0',
|
||||
plugin_protocol: 1,
|
||||
transport_api: 1,
|
||||
ui_bridge: 1,
|
||||
},
|
||||
capabilities: [],
|
||||
ui: { entrypoint: 'ui/index.html' },
|
||||
},
|
||||
binary_sha256: 'a'.repeat(64),
|
||||
signature_status: 'trusted' as const,
|
||||
state: 'disabled' as const,
|
||||
last_error: '',
|
||||
installed_at: '2026-08-22T00:00:00Z',
|
||||
updated_at: '2026-08-22T00:00:00Z',
|
||||
bindings: [
|
||||
{
|
||||
id: 1,
|
||||
plugin_id: 7,
|
||||
capability: 'openai.oauth.outbound_transport.v1',
|
||||
platform: 'openai',
|
||||
account_type: 'oauth',
|
||||
enabled: false,
|
||||
rollout_percent: 100,
|
||||
},
|
||||
],
|
||||
compatibility: {
|
||||
compatible: true,
|
||||
tested: true,
|
||||
status: 'compatible' as const,
|
||||
message: '',
|
||||
current_sub2api_version: '0.1.0',
|
||||
required_sub2api_version: '>=0.1.0',
|
||||
recommended_sub2api_version: '0.1.0',
|
||||
plugin_protocol: 1,
|
||||
transport_api: 1,
|
||||
ui_bridge: 1,
|
||||
},
|
||||
runtime_healthy: false,
|
||||
runtime_message: '',
|
||||
}
|
||||
|
||||
function mountView() {
|
||||
return mount(PluginsView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: { template: '<div><slot /></div>' },
|
||||
BaseDialog: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
TotpStepUpDialog: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('管理员插件页二次验证', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
stepUpRun.mockImplementation((action: () => Promise<unknown>) => action())
|
||||
listPlugins.mockResolvedValue([plugin])
|
||||
uploadPlugin.mockResolvedValue(plugin)
|
||||
enablePlugin.mockResolvedValue(plugin)
|
||||
savePluginConfig.mockResolvedValue({ enabled: true })
|
||||
createUISession.mockResolvedValue({
|
||||
url: '/api/v1/plugin-ui/token/index.html#bridge_token=bridge',
|
||||
bridge_token: 'bridge',
|
||||
ui_bridge_version: 1,
|
||||
expires_at: '2026-08-22T01:00:00Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('启用插件通过 step-up 控制器执行', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
const button = wrapper.findAll('button').find((item) => item.text().includes('admin.plugins.enable'))
|
||||
expect(button).toBeDefined()
|
||||
await button!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(stepUpRun).toHaveBeenCalledTimes(1)
|
||||
expect(enablePlugin).toHaveBeenCalledWith(7, 100, false)
|
||||
})
|
||||
|
||||
it('上传插件通过 step-up 控制器执行', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', {
|
||||
configurable: true,
|
||||
value: [new File(['plugin'], 'transport.s2plugin', { type: 'application/zip' })],
|
||||
})
|
||||
|
||||
await input.trigger('change')
|
||||
await flushPromises()
|
||||
|
||||
expect(stepUpRun).toHaveBeenCalledTimes(1)
|
||||
expect(uploadPlugin).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user