Files
sub2api/backend/internal/service/plugin_manager.go
T

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
}