1092 lines
35 KiB
Go
1092 lines
35 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
|
)
|
|
|
|
const (
|
|
pluginConfigMaxBytes = 4 * 1024 * 1024
|
|
pluginUIAssetMaxBytes = 32 * 1024 * 1024
|
|
pluginReconcilePeriod = time.Second
|
|
pluginHealthTimeout = 5 * time.Second
|
|
pluginUITokenPrefix = "sub2api:plugin-ui:v1:"
|
|
)
|
|
|
|
type pluginRoute struct {
|
|
pluginID int64
|
|
runtime *pluginRuntime
|
|
rolloutPercent int
|
|
unavailable string
|
|
}
|
|
|
|
// PluginManager 管理插件安装、配置、进程生命周期和 OpenAI OAuth 能力绑定。
|
|
type PluginManager struct {
|
|
repo PluginRepository
|
|
encryptor SecretEncryptor
|
|
cfg *config.Config
|
|
hostInfo PluginHostInfo
|
|
installer *PluginPackageInstaller
|
|
|
|
operationMu sync.Mutex
|
|
mu sync.Mutex
|
|
runtimes map[int64]*pluginRuntime
|
|
localInstallations map[int64]*PluginInstallation
|
|
started bool
|
|
reconcileCancel context.CancelFunc
|
|
reconcileDone chan struct{}
|
|
route atomic.Pointer[pluginRoute]
|
|
}
|
|
|
|
func NewPluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo) *PluginManager {
|
|
return &PluginManager{
|
|
repo: repo,
|
|
encryptor: encryptor,
|
|
cfg: cfg,
|
|
hostInfo: hostInfo,
|
|
installer: NewPluginPackageInstaller(cfg, hostInfo),
|
|
runtimes: make(map[int64]*pluginRuntime),
|
|
localInstallations: make(map[int64]*PluginInstallation),
|
|
}
|
|
}
|
|
|
|
func (m *PluginManager) MaxUploadBytes() int64 {
|
|
if m == nil || m.cfg == nil {
|
|
return 0
|
|
}
|
|
return m.cfg.Plugins.MaxUploadBytes
|
|
}
|
|
|
|
func (m *PluginManager) Start(ctx context.Context) error {
|
|
m.operationMu.Lock()
|
|
m.mu.Lock()
|
|
if m.started {
|
|
m.mu.Unlock()
|
|
m.operationMu.Unlock()
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(m.installer.RootDir(), "runtime"), 0o700); err != nil {
|
|
m.mu.Unlock()
|
|
m.operationMu.Unlock()
|
|
return fmt.Errorf("创建插件运行目录: %w", err)
|
|
}
|
|
reconcileCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
|
|
m.started = true
|
|
m.reconcileCancel = cancel
|
|
m.reconcileDone = make(chan struct{})
|
|
done := m.reconcileDone
|
|
m.mu.Unlock()
|
|
m.operationMu.Unlock()
|
|
|
|
go m.reconcileLoop(reconcileCtx, done)
|
|
if err := m.reconcileOnce(reconcileCtx); err != nil {
|
|
slog.Warn("plugin_initial_reconcile_failed", "error", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *PluginManager) Stop() {
|
|
m.mu.Lock()
|
|
cancel := m.reconcileCancel
|
|
done := m.reconcileDone
|
|
m.reconcileCancel = nil
|
|
m.reconcileDone = nil
|
|
m.mu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
if done != nil {
|
|
<-done
|
|
}
|
|
m.operationMu.Lock()
|
|
m.mu.Lock()
|
|
runtimes := make([]*pluginRuntime, 0, len(m.runtimes))
|
|
for _, runtime := range m.runtimes {
|
|
runtimes = append(runtimes, runtime)
|
|
}
|
|
m.runtimes = make(map[int64]*pluginRuntime)
|
|
m.route.Store(nil)
|
|
m.started = false
|
|
m.mu.Unlock()
|
|
m.operationMu.Unlock()
|
|
for _, runtime := range runtimes {
|
|
runtime.drain(10 * time.Second)
|
|
}
|
|
}
|
|
|
|
func (m *PluginManager) List(ctx context.Context) ([]*PluginInstallation, error) {
|
|
plugins, err := m.repo.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
route := m.route.Load()
|
|
for _, installation := range plugins {
|
|
installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo)
|
|
if runtime := m.runtimes[installation.ID]; runtime != nil && !runtime.client.Exited() {
|
|
installation.RuntimeHealthy = true
|
|
installation.RuntimeMessage = "插件进程运行中"
|
|
} else if installation.State == PluginStateEnabled {
|
|
installation.RuntimeMessage = installation.LastError
|
|
}
|
|
if route != nil && route.pluginID == installation.ID && route.runtime == nil {
|
|
installation.RuntimeMessage = route.unavailable
|
|
}
|
|
}
|
|
return plugins, nil
|
|
}
|
|
|
|
func (m *PluginManager) Get(ctx context.Context, id int64) (*PluginInstallation, error) {
|
|
installation, err := m.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo)
|
|
m.mu.Lock()
|
|
runtime := m.runtimes[id]
|
|
m.mu.Unlock()
|
|
installation.RuntimeHealthy = runtime != nil && !runtime.client.Exited()
|
|
if installation.RuntimeHealthy {
|
|
installation.RuntimeMessage = "插件进程运行中"
|
|
} else if route := m.route.Load(); route != nil && route.pluginID == id {
|
|
installation.RuntimeMessage = route.unavailable
|
|
}
|
|
return installation, nil
|
|
}
|
|
|
|
func (m *PluginManager) Install(ctx context.Context, reader io.Reader, installedBy *int64) (*PluginInstallation, error) {
|
|
m.operationMu.Lock()
|
|
defer m.operationMu.Unlock()
|
|
packageInfo, err := m.installer.Install(ctx, reader, installedBy)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var previous *PluginInstallation
|
|
if existing, getErr := m.repo.GetByKey(ctx, packageInfo.PluginKey); getErr == nil {
|
|
if existing.State == PluginStateEnabled || hasEnabledOpenAIBinding(existing.Bindings) {
|
|
cleanupErr := m.cleanupInstallationFiles(packageInfo)
|
|
return nil, errors.Join(errors.New("请先停用当前插件,再上传同 ID 的新版本"), cleanupErr)
|
|
}
|
|
previous = existing
|
|
} else if !errors.Is(getErr, sql.ErrNoRows) {
|
|
cleanupErr := m.cleanupInstallationFiles(packageInfo)
|
|
return nil, errors.Join(getErr, cleanupErr)
|
|
}
|
|
bindings := make([]PluginBinding, 0, len(packageInfo.Manifest.Capabilities))
|
|
for _, capability := range packageInfo.Manifest.SortedCapabilities() {
|
|
bindings = append(bindings, PluginBinding{
|
|
Capability: capability.ID,
|
|
Platform: capability.Platform,
|
|
AccountType: capability.AccountType,
|
|
Enabled: false,
|
|
RolloutPercent: 100,
|
|
})
|
|
}
|
|
installed, err := m.repo.Install(ctx, packageInfo, bindings)
|
|
if err != nil {
|
|
cleanupErr := m.cleanupInstallationFiles(packageInfo)
|
|
return nil, errors.Join(err, cleanupErr)
|
|
}
|
|
local := *packageInfo
|
|
local.ID = installed.ID
|
|
local.ConfigEncrypted = installed.ConfigEncrypted
|
|
local.Bindings = append([]PluginBinding(nil), installed.Bindings...)
|
|
m.mu.Lock()
|
|
localPrevious := m.localInstallations[installed.ID]
|
|
m.localInstallations[installed.ID] = &local
|
|
m.mu.Unlock()
|
|
if previous != nil {
|
|
if cleanupErr := m.cleanupInstallationFiles(previous); cleanupErr != nil {
|
|
slog.Warn("plugin_previous_install_cleanup_failed", "plugin_id", previous.ID, "error", cleanupErr)
|
|
}
|
|
if localPrevious != nil && (localPrevious.InstallPath != previous.InstallPath || localPrevious.ArtifactPath != previous.ArtifactPath) {
|
|
if cleanupErr := m.cleanupInstallationFiles(localPrevious); cleanupErr != nil {
|
|
slog.Warn("plugin_previous_local_install_cleanup_failed", "plugin_id", previous.ID, "error", cleanupErr)
|
|
}
|
|
}
|
|
}
|
|
return m.Get(ctx, installed.ID)
|
|
}
|
|
|
|
func (m *PluginManager) cleanupInstallationFiles(installation *PluginInstallation) error {
|
|
if installation == nil {
|
|
return nil
|
|
}
|
|
var cleanupErr error
|
|
for _, path := range []string{installation.ArtifactPath, installation.InstallPath} {
|
|
if err := m.removeManagedPath(path); err != nil {
|
|
cleanupErr = errors.Join(cleanupErr, err)
|
|
}
|
|
}
|
|
return cleanupErr
|
|
}
|
|
|
|
func (m *PluginManager) reconcileLoop(ctx context.Context, done chan struct{}) {
|
|
defer close(done)
|
|
ticker := time.NewTicker(pluginReconcilePeriod)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := m.reconcileOnce(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
|
slog.Warn("plugin_reconcile_failed", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// reconcileOnce 以数据库中的绑定为权威状态,让每个实例独立恢复并启动同一插件。
|
|
func (m *PluginManager) reconcileOnce(ctx context.Context) error {
|
|
m.operationMu.Lock()
|
|
defer m.operationMu.Unlock()
|
|
|
|
installations, err := m.repo.List(ctx)
|
|
if err != nil {
|
|
// 无法读取权威绑定状态时不能假设插件未启用,否则会把 OAuth 请求静默回落到旧直连路径。
|
|
m.publishUnavailableRoute(0, 100, "插件启用状态暂时无法读取")
|
|
return fmt.Errorf("读取插件启用状态: %w", err)
|
|
}
|
|
m.cleanupStaleLocalInstallations(installations)
|
|
var enabled *PluginInstallation
|
|
for _, installation := range installations {
|
|
if !hasEnabledOpenAIBinding(installation.Bindings) {
|
|
continue
|
|
}
|
|
if enabled != nil {
|
|
err := errors.New("检测到多个 OpenAI OAuth 出站插件同时启用")
|
|
m.publishUnavailableRoute(enabled.ID, 100, err.Error())
|
|
return err
|
|
}
|
|
enabled = installation
|
|
}
|
|
if enabled == nil {
|
|
for _, installation := range installations {
|
|
if installation.State != PluginStateStarting || !m.startingStateExpired(installation) {
|
|
continue
|
|
}
|
|
if err := m.repo.UpdateState(
|
|
ctx, installation.ID, PluginStateDisabled, "插件启动超时,已自动恢复为停用状态", nil,
|
|
installation.BinarySHA256, PluginStateStarting,
|
|
); err != nil && !errors.Is(err, ErrPluginStateChanged) {
|
|
return fmt.Errorf("恢复超时插件状态: %w", err)
|
|
}
|
|
}
|
|
runtimes := m.detachAllRuntimes()
|
|
for _, runtime := range runtimes {
|
|
runtime.drain(10 * time.Second)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
rollout := bindingRollout(enabled.Bindings)
|
|
current := m.route.Load()
|
|
if current != nil && current.pluginID == enabled.ID && current.runtime != nil &&
|
|
!current.runtime.client.Exited() && current.rolloutPercent == rollout &&
|
|
current.runtime.installation.BinarySHA256 == enabled.BinarySHA256 &&
|
|
current.runtime.installation.ConfigEncrypted == enabled.ConfigEncrypted {
|
|
healthCtx, cancel := context.WithTimeout(ctx, pluginHealthTimeout)
|
|
healthErr := current.runtime.checkHealth(healthCtx)
|
|
cancel()
|
|
if healthErr != nil {
|
|
if stateErr := m.markRuntimeUnavailable(current, healthErr.Error()); stateErr != nil {
|
|
return errors.Join(healthErr, stateErr)
|
|
}
|
|
return healthErr
|
|
}
|
|
if enabled.State == PluginStateError || (enabled.State == PluginStateStarting && m.startingStateExpired(enabled)) {
|
|
return m.repo.MarkRuntimeHealthy(ctx, enabled.ID, enabled.BinarySHA256, enabled.ConfigEncrypted)
|
|
}
|
|
return nil
|
|
}
|
|
if enabled.State == PluginStateStarting && !m.startingStateExpired(enabled) {
|
|
if current == nil {
|
|
m.route.Store(&pluginRoute{pluginID: enabled.ID, rolloutPercent: rollout, unavailable: "插件正在其他实例中启动"})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
local, err := m.ensureLocalInstallation(ctx, enabled)
|
|
if err != nil {
|
|
m.publishUnavailableRoute(enabled.ID, rollout, err.Error())
|
|
return err
|
|
}
|
|
runtime, err := m.prepareRuntime(ctx, local, true)
|
|
if err != nil {
|
|
m.publishUnavailableRoute(enabled.ID, rollout, err.Error())
|
|
return err
|
|
}
|
|
|
|
// 启动进程期间绑定可能已在其他实例上变化,发布前必须重新确认。
|
|
latest, err := m.repo.GetByID(ctx, enabled.ID)
|
|
if err != nil {
|
|
runtime.kill()
|
|
return err
|
|
}
|
|
if !hasEnabledOpenAIBinding(latest.Bindings) || latest.BinarySHA256 != enabled.BinarySHA256 ||
|
|
latest.ConfigEncrypted != enabled.ConfigEncrypted || bindingRollout(latest.Bindings) != rollout {
|
|
runtime.kill()
|
|
return nil
|
|
}
|
|
if latest.State == PluginStateStarting && !m.startingStateExpired(latest) {
|
|
runtime.kill()
|
|
return nil
|
|
}
|
|
if err := m.repo.MarkRuntimeHealthy(ctx, enabled.ID, enabled.BinarySHA256, enabled.ConfigEncrypted); err != nil {
|
|
runtime.kill()
|
|
if errors.Is(err, ErrPluginStateChanged) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
m.mu.Lock()
|
|
stale := make([]*pluginRuntime, 0, len(m.runtimes))
|
|
for id, candidate := range m.runtimes {
|
|
if candidate != runtime {
|
|
candidate.draining.Store(true)
|
|
stale = append(stale, candidate)
|
|
}
|
|
if id != enabled.ID {
|
|
delete(m.runtimes, id)
|
|
}
|
|
}
|
|
m.runtimes[enabled.ID] = runtime
|
|
m.route.Store(&pluginRoute{pluginID: enabled.ID, runtime: runtime, rolloutPercent: rollout})
|
|
m.mu.Unlock()
|
|
for _, candidate := range stale {
|
|
candidate.drain(10 * time.Second)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *PluginManager) startingStateExpired(installation *PluginInstallation) bool {
|
|
if installation == nil || installation.UpdatedAt.IsZero() {
|
|
return false
|
|
}
|
|
startTimeout := 15 * time.Second
|
|
if m.cfg != nil && m.cfg.Plugins.StartTimeoutSeconds > 0 {
|
|
startTimeout = time.Duration(m.cfg.Plugins.StartTimeoutSeconds) * time.Second
|
|
}
|
|
recoveryDelay := startTimeout + 45*time.Second
|
|
if recoveryDelay < time.Minute {
|
|
recoveryDelay = time.Minute
|
|
}
|
|
return time.Since(installation.UpdatedAt) > recoveryDelay
|
|
}
|
|
|
|
func (m *PluginManager) detachAllRuntimes() []*pluginRuntime {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
runtimes := make([]*pluginRuntime, 0, len(m.runtimes))
|
|
for id, runtime := range m.runtimes {
|
|
runtime.draining.Store(true)
|
|
runtimes = append(runtimes, runtime)
|
|
delete(m.runtimes, id)
|
|
}
|
|
m.route.Store(nil)
|
|
return runtimes
|
|
}
|
|
|
|
func (m *PluginManager) publishUnavailableRoute(pluginID int64, rollout int, message string) {
|
|
m.mu.Lock()
|
|
stale := make([]*pluginRuntime, 0, len(m.runtimes))
|
|
for id, runtime := range m.runtimes {
|
|
runtime.draining.Store(true)
|
|
stale = append(stale, runtime)
|
|
delete(m.runtimes, id)
|
|
}
|
|
m.route.Store(&pluginRoute{pluginID: pluginID, rolloutPercent: rollout, unavailable: message})
|
|
m.mu.Unlock()
|
|
for _, runtime := range stale {
|
|
runtime.drain(10 * time.Second)
|
|
}
|
|
}
|
|
|
|
func (m *PluginManager) ensureLocalInstallation(ctx context.Context, installation *PluginInstallation) (*PluginInstallation, error) {
|
|
if installation == nil {
|
|
return nil, errors.New("插件安装记录为空")
|
|
}
|
|
m.mu.Lock()
|
|
local := m.localInstallations[installation.ID]
|
|
m.mu.Unlock()
|
|
if local != nil && local.BinarySHA256 == installation.BinarySHA256 && local.Version == installation.Version {
|
|
if err := verifyLocalPluginBinary(local, m.installer.RootDir()); err == nil {
|
|
return mergeLocalInstallation(local, installation), nil
|
|
}
|
|
}
|
|
if err := verifyLocalPluginBinary(installation, m.installer.RootDir()); err == nil {
|
|
local = mergeLocalInstallation(installation, installation)
|
|
m.mu.Lock()
|
|
m.localInstallations[installation.ID] = local
|
|
m.mu.Unlock()
|
|
return local, nil
|
|
}
|
|
|
|
artifact, err := m.repo.GetArtifact(ctx, installation.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("读取插件包原件: %w", err)
|
|
}
|
|
if len(artifact) == 0 {
|
|
return nil, errors.New("插件包原件缺失,请重新上传插件")
|
|
}
|
|
restored, err := m.installer.Install(ctx, bytes.NewReader(artifact), installation.InstalledBy)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("恢复并复验插件包: %w", err)
|
|
}
|
|
if !samePluginPackage(restored, installation) {
|
|
cleanupErr := m.cleanupInstallationFiles(restored)
|
|
return nil, errors.Join(errors.New("数据库插件包与安装记录不一致"), cleanupErr)
|
|
}
|
|
local = mergeLocalInstallation(restored, installation)
|
|
m.mu.Lock()
|
|
m.localInstallations[installation.ID] = local
|
|
m.mu.Unlock()
|
|
return local, nil
|
|
}
|
|
|
|
// cleanupStaleLocalInstallations 回收本实例缓存中已从数据库删除或已被新包替换的文件。
|
|
// 数据库是跨实例的权威状态,本地目录不能因其他实例的卸载/升级永久残留。
|
|
func (m *PluginManager) cleanupStaleLocalInstallations(installations []*PluginInstallation) {
|
|
persisted := make(map[int64]*PluginInstallation, len(installations))
|
|
for _, installation := range installations {
|
|
if installation != nil {
|
|
persisted[installation.ID] = installation
|
|
}
|
|
}
|
|
m.mu.Lock()
|
|
stale := make([]*PluginInstallation, 0)
|
|
for id, local := range m.localInstallations {
|
|
current := persisted[id]
|
|
if current == nil || current.BinarySHA256 != local.BinarySHA256 || current.Version != local.Version {
|
|
stale = append(stale, local)
|
|
delete(m.localInstallations, id)
|
|
}
|
|
}
|
|
m.mu.Unlock()
|
|
for _, local := range stale {
|
|
if err := m.cleanupInstallationFiles(local); err != nil {
|
|
slog.Warn("plugin_stale_local_install_cleanup_failed", "plugin_id", local.ID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mergeLocalInstallation(local, persisted *PluginInstallation) *PluginInstallation {
|
|
merged := *persisted
|
|
merged.ArtifactData = nil
|
|
merged.ArtifactPath = local.ArtifactPath
|
|
merged.InstallPath = local.InstallPath
|
|
merged.BinaryPath = local.BinaryPath
|
|
merged.Bindings = append([]PluginBinding(nil), persisted.Bindings...)
|
|
return &merged
|
|
}
|
|
|
|
func samePluginPackage(local, persisted *PluginInstallation) bool {
|
|
if local == nil || persisted == nil || local.PluginKey != persisted.PluginKey ||
|
|
local.Version != persisted.Version || local.BinarySHA256 != persisted.BinarySHA256 {
|
|
return false
|
|
}
|
|
localManifest, localErr := json.Marshal(local.Manifest)
|
|
persistedManifest, persistedErr := json.Marshal(persisted.Manifest)
|
|
return localErr == nil && persistedErr == nil && bytes.Equal(localManifest, persistedManifest)
|
|
}
|
|
|
|
func verifyLocalPluginBinary(installation *PluginInstallation, root string) error {
|
|
if installation == nil {
|
|
return errors.New("插件安装记录为空")
|
|
}
|
|
rootPath, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
installPath, err := filepath.Abs(installation.InstallPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
relative, err := filepath.Rel(rootPath, installPath)
|
|
if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
|
return errors.New("插件安装目录不在受管目录内")
|
|
}
|
|
runtimeEntry, ok := installation.Manifest.Runtimes[installation.Manifest.RuntimeKey()]
|
|
if !ok {
|
|
return errors.New("插件未声明当前平台运行时")
|
|
}
|
|
binaryPath, err := safePluginJoin(installPath, runtimeEntry.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := os.ReadFile(binaryPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
digest := sha256.Sum256(data)
|
|
if hex.EncodeToString(digest[:]) != installation.BinarySHA256 {
|
|
return errors.New("本地插件二进制哈希不匹配")
|
|
}
|
|
installation.BinaryPath = binaryPath
|
|
return nil
|
|
}
|
|
|
|
func (m *PluginManager) Enable(ctx context.Context, id int64, acceptUntested bool, rolloutPercent int) (*PluginInstallation, error) {
|
|
m.operationMu.Lock()
|
|
defer m.operationMu.Unlock()
|
|
if rolloutPercent < 1 || rolloutPercent > 100 {
|
|
return nil, errors.New("灰度比例必须在 1 到 100 之间")
|
|
}
|
|
installation, err := m.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if active := m.route.Load(); active != nil && active.pluginID != id {
|
|
return nil, errors.New("OpenAI OAuth 出站能力已有启用插件,请先停用当前插件")
|
|
}
|
|
if installation.State == PluginStateEnabled && hasEnabledOpenAIBinding(installation.Bindings) {
|
|
installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo)
|
|
m.mu.Lock()
|
|
runtime := m.runtimes[id]
|
|
m.mu.Unlock()
|
|
installation.RuntimeHealthy = runtime != nil && !runtime.client.Exited()
|
|
if installation.RuntimeHealthy {
|
|
return installation, nil
|
|
}
|
|
}
|
|
compatibility := EvaluatePluginCompatibility(installation.Manifest, m.hostInfo)
|
|
if !compatibility.Compatible {
|
|
stateErr := m.repo.UpdateState(ctx, id, PluginStateIncompatible, compatibility.Message, nil, installation.BinarySHA256, installation.State)
|
|
return nil, errors.Join(errors.New(compatibility.Message), stateErr)
|
|
}
|
|
if !compatibility.Tested && !acceptUntested {
|
|
return nil, errors.New("插件未声明已测试当前 Sub2API 版本,需要管理员确认后启用")
|
|
}
|
|
installation, err = m.ensureLocalInstallation(ctx, installation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
originalBindings := append([]PluginBinding(nil), installation.Bindings...)
|
|
for index := range installation.Bindings {
|
|
installation.Bindings[index].Enabled = true
|
|
installation.Bindings[index].RolloutPercent = rolloutPercent
|
|
}
|
|
if err := m.repo.BeginEnable(ctx, id, installation.BinarySHA256, installation.State); err != nil {
|
|
return nil, err
|
|
}
|
|
runtime, err := m.prepareRuntime(ctx, installation, true)
|
|
if err != nil {
|
|
stateCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
|
stateErr := m.repo.UpdateState(stateCtx, id, PluginStateError, err.Error(), nil, installation.BinarySHA256, PluginStateStarting)
|
|
cancel()
|
|
if hasEnabledOpenAIBinding(originalBindings) {
|
|
m.route.Store(&pluginRoute{pluginID: id, rolloutPercent: bindingRollout(originalBindings), unavailable: err.Error()})
|
|
}
|
|
return nil, errors.Join(err, stateErr)
|
|
}
|
|
now := time.Now()
|
|
if err := m.repo.UpdateBindingsAndState(ctx, id, installation.Bindings, PluginStateEnabled, "", &now, PluginStateStarting, installation.BinarySHA256); err != nil {
|
|
runtime.kill()
|
|
if errors.Is(err, ErrPluginStateChanged) {
|
|
return nil, err
|
|
}
|
|
stateCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
|
stateErr := m.repo.UpdateState(stateCtx, id, PluginStateError, err.Error(), nil, installation.BinarySHA256, PluginStateStarting)
|
|
cancel()
|
|
return nil, errors.Join(err, stateErr)
|
|
}
|
|
m.mu.Lock()
|
|
m.publishRuntimeLocked(installation, runtime)
|
|
m.mu.Unlock()
|
|
result, err := m.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.Compatibility = compatibility
|
|
result.RuntimeHealthy = true
|
|
result.RuntimeMessage = "插件进程运行中"
|
|
return result, nil
|
|
}
|
|
|
|
func (m *PluginManager) Disable(ctx context.Context, id int64) (*PluginInstallation, error) {
|
|
m.operationMu.Lock()
|
|
defer m.operationMu.Unlock()
|
|
m.mu.Lock()
|
|
installation, err := m.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
m.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
for index := range installation.Bindings {
|
|
installation.Bindings[index].Enabled = false
|
|
}
|
|
if err := m.repo.UpdateBindingsAndState(ctx, id, installation.Bindings, PluginStateDisabled, "", nil, "", installation.BinarySHA256); err != nil {
|
|
m.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
runtime := m.removeRuntimeLocked(id)
|
|
m.mu.Unlock()
|
|
if runtime != nil {
|
|
runtime.drain(10 * time.Second)
|
|
}
|
|
return m.Get(ctx, id)
|
|
}
|
|
|
|
func (m *PluginManager) Delete(ctx context.Context, id int64) error {
|
|
m.operationMu.Lock()
|
|
defer m.operationMu.Unlock()
|
|
m.mu.Lock()
|
|
installation, err := m.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
m.mu.Unlock()
|
|
return err
|
|
}
|
|
if installation.State == PluginStateEnabled || hasEnabledOpenAIBinding(installation.Bindings) {
|
|
m.mu.Unlock()
|
|
return errors.New("请先停用插件,再执行卸载")
|
|
}
|
|
if err := m.repo.Delete(ctx, id, installation.BinarySHA256); err != nil {
|
|
m.mu.Unlock()
|
|
return err
|
|
}
|
|
runtime := m.removeRuntimeLocked(id)
|
|
local := m.localInstallations[id]
|
|
delete(m.localInstallations, id)
|
|
m.mu.Unlock()
|
|
if runtime != nil {
|
|
runtime.drain(10 * time.Second)
|
|
}
|
|
cleanupErr := m.cleanupInstallationFiles(installation)
|
|
if local != nil && (local.InstallPath != installation.InstallPath || local.ArtifactPath != installation.ArtifactPath) {
|
|
cleanupErr = errors.Join(cleanupErr, m.cleanupInstallationFiles(local))
|
|
}
|
|
return cleanupErr
|
|
}
|
|
|
|
func (m *PluginManager) GetConfig(ctx context.Context, id int64) (json.RawMessage, error) {
|
|
installation, err := m.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return m.decryptConfig(installation)
|
|
}
|
|
|
|
func (m *PluginManager) SaveConfig(ctx context.Context, id int64, raw json.RawMessage) (json.RawMessage, error) {
|
|
m.operationMu.Lock()
|
|
defer m.operationMu.Unlock()
|
|
if len(raw) == 0 || len(raw) > pluginConfigMaxBytes || !json.Valid(raw) {
|
|
return nil, errors.New("插件配置必须是有效且大小受限的 JSON")
|
|
}
|
|
installation, err := m.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
previousConfig, err := m.decryptConfig(installation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var normalized any
|
|
if err := json.Unmarshal(raw, &normalized); err != nil {
|
|
return nil, err
|
|
}
|
|
if normalized == nil {
|
|
return nil, errors.New("插件配置 JSON 根节点必须是对象")
|
|
}
|
|
if _, ok := normalized.(map[string]any); !ok {
|
|
return nil, errors.New("插件配置 JSON 根节点必须是对象")
|
|
}
|
|
canonical, err := json.Marshal(normalized)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.mu.Lock()
|
|
runtime := m.runtimes[id]
|
|
m.mu.Unlock()
|
|
temporary := false
|
|
if runtime == nil {
|
|
installation, err = m.ensureLocalInstallation(ctx, installation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
runtime, err = m.newRuntime(ctx, installation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
temporary = true
|
|
defer runtime.kill()
|
|
}
|
|
if runtime != nil {
|
|
applyCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
canonical, err = runtime.validateAndApplyNormalizedConfig(applyCtx, canonical)
|
|
cancel()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
encrypted, err := m.encryptor.Encrypt(string(canonical))
|
|
if err != nil {
|
|
if !temporary {
|
|
err = errors.Join(err, m.restoreRuntimeConfig(id, runtime, previousConfig))
|
|
}
|
|
return nil, fmt.Errorf("加密插件配置: %w", err)
|
|
}
|
|
if err := m.repo.UpdateConfig(ctx, id, encrypted, installation.BinarySHA256); err != nil {
|
|
if !temporary {
|
|
err = errors.Join(err, m.restoreRuntimeConfig(id, runtime, previousConfig))
|
|
}
|
|
return nil, err
|
|
}
|
|
if !temporary {
|
|
runtime.installation.ConfigEncrypted = encrypted
|
|
}
|
|
return canonical, nil
|
|
}
|
|
|
|
func (m *PluginManager) restoreRuntimeConfig(id int64, runtime *pluginRuntime, previous json.RawMessage) error {
|
|
if runtime == nil {
|
|
return nil
|
|
}
|
|
rollbackCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
if err := runtime.validateAndApplyConfig(rollbackCtx, previous); err != nil {
|
|
route := m.route.Load()
|
|
if route != nil && route.pluginID == id && route.runtime == runtime {
|
|
stateErr := m.markRuntimeUnavailable(route, "插件配置回滚失败: "+err.Error())
|
|
return errors.Join(err, stateErr)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *PluginManager) Test(ctx context.Context, id int64) (*pluginv1.TestConfigResponse, error) {
|
|
m.operationMu.Lock()
|
|
defer m.operationMu.Unlock()
|
|
installation, err := m.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
configJSON, err := m.decryptConfig(installation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.mu.Lock()
|
|
runtime := m.runtimes[id]
|
|
m.mu.Unlock()
|
|
temporary := false
|
|
if runtime == nil {
|
|
installation, err = m.ensureLocalInstallation(ctx, installation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
runtime, err = m.newRuntime(ctx, installation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
temporary = true
|
|
}
|
|
if temporary {
|
|
defer runtime.kill()
|
|
}
|
|
testCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
if err := runtime.validateAndApplyConfig(testCtx, configJSON); err != nil {
|
|
return nil, err
|
|
}
|
|
return runtime.api.TestConfig(testCtx, &pluginv1.TestConfigRequest{ConfigJson: configJSON})
|
|
}
|
|
|
|
type pluginUIAssetClaims struct {
|
|
Version int `json:"version"`
|
|
PluginID int64 `json:"plugin_id"`
|
|
Expires int64 `json:"expires"`
|
|
}
|
|
|
|
// CreateUIAssetToken 创建可跨实例校验的短时能力令牌,令牌不包含管理员凭据。
|
|
func (m *PluginManager) CreateUIAssetToken(ctx context.Context, id int64, ttl time.Duration) (string, time.Time, error) {
|
|
if ttl <= 0 || ttl > time.Hour {
|
|
return "", time.Time{}, errors.New("插件 UI 会话有效期无效")
|
|
}
|
|
if _, err := m.repo.GetByID(ctx, id); err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
expires := time.Now().Add(ttl)
|
|
raw, err := json.Marshal(pluginUIAssetClaims{Version: 1, PluginID: id, Expires: expires.Unix()})
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
// 加用途前缀,避免复用同一 AES-GCM 密钥的其他密文被当作 UI 能力令牌。
|
|
encrypted, err := m.encryptor.Encrypt(pluginUITokenPrefix + string(raw))
|
|
if err != nil {
|
|
return "", time.Time{}, fmt.Errorf("加密插件 UI 会话: %w", err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString([]byte(encrypted)), expires, nil
|
|
}
|
|
|
|
func (m *PluginManager) ResolveUIAssetToken(token string) (int64, error) {
|
|
if len(token) == 0 || len(token) > 4096 {
|
|
return 0, errors.New("插件 UI 会话无效")
|
|
}
|
|
encrypted, err := base64.RawURLEncoding.DecodeString(token)
|
|
if err != nil {
|
|
return 0, errors.New("插件 UI 会话无效")
|
|
}
|
|
plaintext, err := m.encryptor.Decrypt(string(encrypted))
|
|
if err != nil {
|
|
return 0, errors.New("插件 UI 会话无效")
|
|
}
|
|
plaintext, ok := strings.CutPrefix(plaintext, pluginUITokenPrefix)
|
|
if !ok {
|
|
return 0, errors.New("插件 UI 会话无效")
|
|
}
|
|
var claims pluginUIAssetClaims
|
|
decoder := json.NewDecoder(strings.NewReader(plaintext))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&claims); err != nil || claims.Version != 1 || claims.PluginID <= 0 {
|
|
return 0, errors.New("插件 UI 会话无效")
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
return 0, errors.New("插件 UI 会话无效")
|
|
}
|
|
now := time.Now().Unix()
|
|
if now >= claims.Expires {
|
|
return 0, errors.New("插件 UI 会话已过期")
|
|
}
|
|
if claims.Expires > now+int64(time.Hour/time.Second) {
|
|
return 0, errors.New("插件 UI 会话无效")
|
|
}
|
|
return claims.PluginID, nil
|
|
}
|
|
|
|
func (m *PluginManager) ReadUIAsset(ctx context.Context, id int64, relative string) ([]byte, string, error) {
|
|
m.operationMu.Lock()
|
|
defer m.operationMu.Unlock()
|
|
installation, err := m.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
installation, err = m.ensureLocalInstallation(ctx, installation)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
path := strings.TrimPrefix(strings.ReplaceAll(relative, "\\", "/"), "/")
|
|
if path == "" || path == "index.html" {
|
|
path = installation.Manifest.UI.Entrypoint
|
|
} else {
|
|
path = "ui/" + path
|
|
}
|
|
if _, declared := installation.Manifest.Files[path]; !declared || !strings.HasPrefix(path, "ui/") {
|
|
return nil, "", os.ErrNotExist
|
|
}
|
|
fullPath, err := safePluginJoin(installation.InstallPath, path)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
file, err := os.Open(fullPath)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
data, err := io.ReadAll(io.LimitReader(file, pluginUIAssetMaxBytes+1))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if len(data) > pluginUIAssetMaxBytes {
|
|
return nil, "", errors.New("插件 UI 资源超过大小限制")
|
|
}
|
|
digest := sha256.Sum256(data)
|
|
if hex.EncodeToString(digest[:]) != installation.Manifest.Files[path] {
|
|
return nil, "", errors.New("插件 UI 资源哈希不匹配")
|
|
}
|
|
return data, path, nil
|
|
}
|
|
|
|
func (m *PluginManager) RoundTripOpenAIOAuth(ctx context.Context, request *http.Request, proxyURL string, account *Account) (*http.Response, bool, error) {
|
|
if !m.ShouldRouteOpenAIOAuth(account) {
|
|
return nil, false, nil
|
|
}
|
|
route := m.route.Load()
|
|
if route == nil {
|
|
return nil, false, nil
|
|
}
|
|
if route.runtime == nil {
|
|
return nil, true, fmt.Errorf("OpenAI OAuth 插件不可用: %s", route.unavailable)
|
|
}
|
|
if route.runtime.client.Exited() {
|
|
runtimeErr := errors.New("OpenAI OAuth 插件进程已退出")
|
|
if stateErr := m.markRuntimeUnavailable(route, runtimeErr.Error()); stateErr != nil {
|
|
return nil, true, errors.Join(runtimeErr, stateErr)
|
|
}
|
|
return nil, true, runtimeErr
|
|
}
|
|
if !route.runtime.beginRequest() {
|
|
return nil, true, errors.New("OpenAI OAuth 插件正在停止")
|
|
}
|
|
response, err := route.runtime.roundTrip(ctx, request, proxyURL, account)
|
|
if err != nil {
|
|
route.runtime.finishRequest()
|
|
if route.runtime.client.Exited() {
|
|
if stateErr := m.markRuntimeUnavailable(route, err.Error()); stateErr != nil {
|
|
err = errors.Join(err, stateErr)
|
|
}
|
|
}
|
|
return nil, true, err
|
|
}
|
|
return response, true, nil
|
|
}
|
|
|
|
// ShouldRouteOpenAIOAuth 判断该账号是否命中当前 OpenAI OAuth 插件绑定。
|
|
// WebSocket 入口用它把命中的账号切换到 HTTP Bridge,避免绕过 v1 HTTP 插件协议。
|
|
func (m *PluginManager) ShouldRouteOpenAIOAuth(account *Account) bool {
|
|
if m == nil || account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth {
|
|
return false
|
|
}
|
|
route := m.route.Load()
|
|
return route != nil && route.rolloutPercent > 0 && int(stablePluginBucket(account.ID)) < route.rolloutPercent
|
|
}
|
|
|
|
func (m *PluginManager) markRuntimeUnavailable(failedRoute *pluginRoute, message string) error {
|
|
m.mu.Lock()
|
|
current := m.route.Load()
|
|
if current != failedRoute {
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
if failedRoute.runtime != nil {
|
|
failedRoute.runtime.kill()
|
|
}
|
|
delete(m.runtimes, failedRoute.pluginID)
|
|
m.route.Store(&pluginRoute{
|
|
pluginID: failedRoute.pluginID,
|
|
rolloutPercent: failedRoute.rolloutPercent,
|
|
unavailable: message,
|
|
})
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (m *PluginManager) prepareRuntime(ctx context.Context, installation *PluginInstallation, validateConfig bool) (*pluginRuntime, error) {
|
|
runtime, err := m.newRuntime(ctx, installation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
configJSON, err := m.decryptConfig(installation)
|
|
if err == nil && validateConfig {
|
|
applyCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
err = runtime.validateAndApplyConfig(applyCtx, configJSON)
|
|
cancel()
|
|
}
|
|
if err != nil {
|
|
runtime.kill()
|
|
return nil, err
|
|
}
|
|
return runtime, nil
|
|
}
|
|
|
|
func (m *PluginManager) publishRuntimeLocked(installation *PluginInstallation, runtime *pluginRuntime) {
|
|
if old := m.runtimes[installation.ID]; old != nil {
|
|
old.kill()
|
|
}
|
|
m.runtimes[installation.ID] = runtime
|
|
m.route.Store(&pluginRoute{
|
|
pluginID: installation.ID,
|
|
runtime: runtime,
|
|
rolloutPercent: bindingRollout(installation.Bindings),
|
|
})
|
|
}
|
|
|
|
func (m *PluginManager) newRuntime(ctx context.Context, installation *PluginInstallation) (*pluginRuntime, error) {
|
|
socketDir := filepath.Join(m.installer.RootDir(), "runtime")
|
|
if err := os.MkdirAll(socketDir, 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
timeout := time.Duration(m.cfg.Plugins.StartTimeoutSeconds) * time.Second
|
|
return startPluginRuntime(ctx, installation, timeout, socketDir)
|
|
}
|
|
|
|
func (m *PluginManager) removeRuntimeLocked(id int64) *pluginRuntime {
|
|
runtime := m.runtimes[id]
|
|
delete(m.runtimes, id)
|
|
if route := m.route.Load(); route != nil && route.pluginID == id {
|
|
m.route.Store(nil)
|
|
}
|
|
return runtime
|
|
}
|
|
|
|
func (m *PluginManager) decryptConfig(installation *PluginInstallation) (json.RawMessage, error) {
|
|
if installation == nil || strings.TrimSpace(installation.ConfigEncrypted) == "" {
|
|
return json.RawMessage(`{}`), nil
|
|
}
|
|
plaintext, err := m.encryptor.Decrypt(installation.ConfigEncrypted)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("解密插件配置: %w", err)
|
|
}
|
|
if !json.Valid([]byte(plaintext)) {
|
|
return nil, errors.New("已保存的插件配置不是有效 JSON")
|
|
}
|
|
return json.RawMessage(plaintext), nil
|
|
}
|
|
|
|
func (m *PluginManager) removeManagedPath(target string) error {
|
|
if strings.TrimSpace(target) == "" {
|
|
return nil
|
|
}
|
|
root, err := filepath.Abs(m.installer.RootDir())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
absTarget, err := filepath.Abs(target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
relative, err := filepath.Rel(root, absTarget)
|
|
if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
|
return errors.New("拒绝删除插件根目录之外的路径")
|
|
}
|
|
return os.RemoveAll(absTarget)
|
|
}
|
|
|
|
func hasEnabledOpenAIBinding(bindings []PluginBinding) bool {
|
|
for _, binding := range bindings {
|
|
if binding.Enabled && binding.Capability == PluginCapabilityOpenAIOAuthOutbound &&
|
|
binding.Platform == PlatformOpenAI && binding.AccountType == AccountTypeOAuth {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func bindingRollout(bindings []PluginBinding) int {
|
|
for _, binding := range bindings {
|
|
if binding.Capability == PluginCapabilityOpenAIOAuthOutbound {
|
|
return binding.RolloutPercent
|
|
}
|
|
}
|
|
return 100
|
|
}
|
|
|
|
func stablePluginBucket(accountID int64) uint64 {
|
|
value := uint64(accountID)
|
|
value ^= value >> 33
|
|
value *= 0xff51afd7ed558ccd
|
|
value ^= value >> 33
|
|
return value % 100
|
|
}
|