plugin_manager.go 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/sha256"
  6. "database/sql"
  7. "encoding/base64"
  8. "encoding/hex"
  9. "encoding/json"
  10. "errors"
  11. "fmt"
  12. "io"
  13. "log/slog"
  14. "net/http"
  15. "os"
  16. "path/filepath"
  17. "strings"
  18. "sync"
  19. "sync/atomic"
  20. "time"
  21. "github.com/Wei-Shaw/sub2api/internal/config"
  22. pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
  23. )
  24. const (
  25. pluginConfigMaxBytes = 4 * 1024 * 1024
  26. pluginUIAssetMaxBytes = 32 * 1024 * 1024
  27. pluginReconcilePeriod = time.Second
  28. pluginHealthTimeout = 5 * time.Second
  29. pluginUITokenPrefix = "sub2api:plugin-ui:v1:"
  30. )
  31. type pluginRoute struct {
  32. pluginID int64
  33. runtime *pluginRuntime
  34. rolloutPercent int
  35. unavailable string
  36. }
  37. // PluginManager 管理插件安装、配置、进程生命周期和 OpenAI OAuth 能力绑定。
  38. type PluginManager struct {
  39. repo PluginRepository
  40. encryptor SecretEncryptor
  41. cfg *config.Config
  42. hostInfo PluginHostInfo
  43. installer *PluginPackageInstaller
  44. operationMu sync.Mutex
  45. mu sync.Mutex
  46. runtimes map[int64]*pluginRuntime
  47. localInstallations map[int64]*PluginInstallation
  48. started bool
  49. reconcileCancel context.CancelFunc
  50. reconcileDone chan struct{}
  51. route atomic.Pointer[pluginRoute]
  52. }
  53. func NewPluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo) *PluginManager {
  54. return &PluginManager{
  55. repo: repo,
  56. encryptor: encryptor,
  57. cfg: cfg,
  58. hostInfo: hostInfo,
  59. installer: NewPluginPackageInstaller(cfg, hostInfo),
  60. runtimes: make(map[int64]*pluginRuntime),
  61. localInstallations: make(map[int64]*PluginInstallation),
  62. }
  63. }
  64. func (m *PluginManager) MaxUploadBytes() int64 {
  65. if m == nil || m.cfg == nil {
  66. return 0
  67. }
  68. return m.cfg.Plugins.MaxUploadBytes
  69. }
  70. func (m *PluginManager) Start(ctx context.Context) error {
  71. m.operationMu.Lock()
  72. m.mu.Lock()
  73. if m.started {
  74. m.mu.Unlock()
  75. m.operationMu.Unlock()
  76. return nil
  77. }
  78. if err := os.MkdirAll(filepath.Join(m.installer.RootDir(), "runtime"), 0o700); err != nil {
  79. m.mu.Unlock()
  80. m.operationMu.Unlock()
  81. return fmt.Errorf("创建插件运行目录: %w", err)
  82. }
  83. reconcileCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
  84. m.started = true
  85. m.reconcileCancel = cancel
  86. m.reconcileDone = make(chan struct{})
  87. done := m.reconcileDone
  88. m.mu.Unlock()
  89. m.operationMu.Unlock()
  90. go m.reconcileLoop(reconcileCtx, done)
  91. if err := m.reconcileOnce(reconcileCtx); err != nil {
  92. slog.Warn("plugin_initial_reconcile_failed", "error", err)
  93. }
  94. return nil
  95. }
  96. func (m *PluginManager) Stop() {
  97. m.mu.Lock()
  98. cancel := m.reconcileCancel
  99. done := m.reconcileDone
  100. m.reconcileCancel = nil
  101. m.reconcileDone = nil
  102. m.mu.Unlock()
  103. if cancel != nil {
  104. cancel()
  105. }
  106. if done != nil {
  107. <-done
  108. }
  109. m.operationMu.Lock()
  110. m.mu.Lock()
  111. runtimes := make([]*pluginRuntime, 0, len(m.runtimes))
  112. for _, runtime := range m.runtimes {
  113. runtimes = append(runtimes, runtime)
  114. }
  115. m.runtimes = make(map[int64]*pluginRuntime)
  116. m.route.Store(nil)
  117. m.started = false
  118. m.mu.Unlock()
  119. m.operationMu.Unlock()
  120. for _, runtime := range runtimes {
  121. runtime.drain(10 * time.Second)
  122. }
  123. }
  124. func (m *PluginManager) List(ctx context.Context) ([]*PluginInstallation, error) {
  125. plugins, err := m.repo.List(ctx)
  126. if err != nil {
  127. return nil, err
  128. }
  129. m.mu.Lock()
  130. defer m.mu.Unlock()
  131. route := m.route.Load()
  132. for _, installation := range plugins {
  133. installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo)
  134. if runtime := m.runtimes[installation.ID]; runtime != nil && !runtime.client.Exited() {
  135. installation.RuntimeHealthy = true
  136. installation.RuntimeMessage = "插件进程运行中"
  137. } else if installation.State == PluginStateEnabled {
  138. installation.RuntimeMessage = installation.LastError
  139. }
  140. if route != nil && route.pluginID == installation.ID && route.runtime == nil {
  141. installation.RuntimeMessage = route.unavailable
  142. }
  143. }
  144. return plugins, nil
  145. }
  146. func (m *PluginManager) Get(ctx context.Context, id int64) (*PluginInstallation, error) {
  147. installation, err := m.repo.GetByID(ctx, id)
  148. if err != nil {
  149. return nil, err
  150. }
  151. installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo)
  152. m.mu.Lock()
  153. runtime := m.runtimes[id]
  154. m.mu.Unlock()
  155. installation.RuntimeHealthy = runtime != nil && !runtime.client.Exited()
  156. if installation.RuntimeHealthy {
  157. installation.RuntimeMessage = "插件进程运行中"
  158. } else if route := m.route.Load(); route != nil && route.pluginID == id {
  159. installation.RuntimeMessage = route.unavailable
  160. }
  161. return installation, nil
  162. }
  163. func (m *PluginManager) Install(ctx context.Context, reader io.Reader, installedBy *int64) (*PluginInstallation, error) {
  164. m.operationMu.Lock()
  165. defer m.operationMu.Unlock()
  166. packageInfo, err := m.installer.Install(ctx, reader, installedBy)
  167. if err != nil {
  168. return nil, err
  169. }
  170. var previous *PluginInstallation
  171. if existing, getErr := m.repo.GetByKey(ctx, packageInfo.PluginKey); getErr == nil {
  172. if existing.State == PluginStateEnabled || hasEnabledOpenAIBinding(existing.Bindings) {
  173. cleanupErr := m.cleanupInstallationFiles(packageInfo)
  174. return nil, errors.Join(errors.New("请先停用当前插件,再上传同 ID 的新版本"), cleanupErr)
  175. }
  176. previous = existing
  177. } else if !errors.Is(getErr, sql.ErrNoRows) {
  178. cleanupErr := m.cleanupInstallationFiles(packageInfo)
  179. return nil, errors.Join(getErr, cleanupErr)
  180. }
  181. bindings := make([]PluginBinding, 0, len(packageInfo.Manifest.Capabilities))
  182. for _, capability := range packageInfo.Manifest.SortedCapabilities() {
  183. bindings = append(bindings, PluginBinding{
  184. Capability: capability.ID,
  185. Platform: capability.Platform,
  186. AccountType: capability.AccountType,
  187. Enabled: false,
  188. RolloutPercent: 100,
  189. })
  190. }
  191. installed, err := m.repo.Install(ctx, packageInfo, bindings)
  192. if err != nil {
  193. cleanupErr := m.cleanupInstallationFiles(packageInfo)
  194. return nil, errors.Join(err, cleanupErr)
  195. }
  196. local := *packageInfo
  197. local.ID = installed.ID
  198. local.ConfigEncrypted = installed.ConfigEncrypted
  199. local.Bindings = append([]PluginBinding(nil), installed.Bindings...)
  200. m.mu.Lock()
  201. localPrevious := m.localInstallations[installed.ID]
  202. m.localInstallations[installed.ID] = &local
  203. m.mu.Unlock()
  204. if previous != nil {
  205. if cleanupErr := m.cleanupInstallationFiles(previous); cleanupErr != nil {
  206. slog.Warn("plugin_previous_install_cleanup_failed", "plugin_id", previous.ID, "error", cleanupErr)
  207. }
  208. if localPrevious != nil && (localPrevious.InstallPath != previous.InstallPath || localPrevious.ArtifactPath != previous.ArtifactPath) {
  209. if cleanupErr := m.cleanupInstallationFiles(localPrevious); cleanupErr != nil {
  210. slog.Warn("plugin_previous_local_install_cleanup_failed", "plugin_id", previous.ID, "error", cleanupErr)
  211. }
  212. }
  213. }
  214. return m.Get(ctx, installed.ID)
  215. }
  216. func (m *PluginManager) cleanupInstallationFiles(installation *PluginInstallation) error {
  217. if installation == nil {
  218. return nil
  219. }
  220. var cleanupErr error
  221. for _, path := range []string{installation.ArtifactPath, installation.InstallPath} {
  222. if err := m.removeManagedPath(path); err != nil {
  223. cleanupErr = errors.Join(cleanupErr, err)
  224. }
  225. }
  226. return cleanupErr
  227. }
  228. func (m *PluginManager) reconcileLoop(ctx context.Context, done chan struct{}) {
  229. defer close(done)
  230. ticker := time.NewTicker(pluginReconcilePeriod)
  231. defer ticker.Stop()
  232. for {
  233. select {
  234. case <-ctx.Done():
  235. return
  236. case <-ticker.C:
  237. if err := m.reconcileOnce(ctx); err != nil && !errors.Is(err, context.Canceled) {
  238. slog.Warn("plugin_reconcile_failed", "error", err)
  239. }
  240. }
  241. }
  242. }
  243. // reconcileOnce 以数据库中的绑定为权威状态,让每个实例独立恢复并启动同一插件。
  244. func (m *PluginManager) reconcileOnce(ctx context.Context) error {
  245. m.operationMu.Lock()
  246. defer m.operationMu.Unlock()
  247. installations, err := m.repo.List(ctx)
  248. if err != nil {
  249. // 无法读取权威绑定状态时不能假设插件未启用,否则会把 OAuth 请求静默回落到旧直连路径。
  250. m.publishUnavailableRoute(0, 100, "插件启用状态暂时无法读取")
  251. return fmt.Errorf("读取插件启用状态: %w", err)
  252. }
  253. m.cleanupStaleLocalInstallations(installations)
  254. var enabled *PluginInstallation
  255. for _, installation := range installations {
  256. if !hasEnabledOpenAIBinding(installation.Bindings) {
  257. continue
  258. }
  259. if enabled != nil {
  260. err := errors.New("检测到多个 OpenAI OAuth 出站插件同时启用")
  261. m.publishUnavailableRoute(enabled.ID, 100, err.Error())
  262. return err
  263. }
  264. enabled = installation
  265. }
  266. if enabled == nil {
  267. for _, installation := range installations {
  268. if installation.State != PluginStateStarting || !m.startingStateExpired(installation) {
  269. continue
  270. }
  271. if err := m.repo.UpdateState(
  272. ctx, installation.ID, PluginStateDisabled, "插件启动超时,已自动恢复为停用状态", nil,
  273. installation.BinarySHA256, PluginStateStarting,
  274. ); err != nil && !errors.Is(err, ErrPluginStateChanged) {
  275. return fmt.Errorf("恢复超时插件状态: %w", err)
  276. }
  277. }
  278. runtimes := m.detachAllRuntimes()
  279. for _, runtime := range runtimes {
  280. runtime.drain(10 * time.Second)
  281. }
  282. return nil
  283. }
  284. rollout := bindingRollout(enabled.Bindings)
  285. current := m.route.Load()
  286. if current != nil && current.pluginID == enabled.ID && current.runtime != nil &&
  287. !current.runtime.client.Exited() && current.rolloutPercent == rollout &&
  288. current.runtime.installation.BinarySHA256 == enabled.BinarySHA256 &&
  289. current.runtime.installation.ConfigEncrypted == enabled.ConfigEncrypted {
  290. healthCtx, cancel := context.WithTimeout(ctx, pluginHealthTimeout)
  291. healthErr := current.runtime.checkHealth(healthCtx)
  292. cancel()
  293. if healthErr != nil {
  294. if stateErr := m.markRuntimeUnavailable(current, healthErr.Error()); stateErr != nil {
  295. return errors.Join(healthErr, stateErr)
  296. }
  297. return healthErr
  298. }
  299. if enabled.State == PluginStateError || (enabled.State == PluginStateStarting && m.startingStateExpired(enabled)) {
  300. return m.repo.MarkRuntimeHealthy(ctx, enabled.ID, enabled.BinarySHA256, enabled.ConfigEncrypted)
  301. }
  302. return nil
  303. }
  304. if enabled.State == PluginStateStarting && !m.startingStateExpired(enabled) {
  305. if current == nil {
  306. m.route.Store(&pluginRoute{pluginID: enabled.ID, rolloutPercent: rollout, unavailable: "插件正在其他实例中启动"})
  307. }
  308. return nil
  309. }
  310. local, err := m.ensureLocalInstallation(ctx, enabled)
  311. if err != nil {
  312. m.publishUnavailableRoute(enabled.ID, rollout, err.Error())
  313. return err
  314. }
  315. runtime, err := m.prepareRuntime(ctx, local, true)
  316. if err != nil {
  317. m.publishUnavailableRoute(enabled.ID, rollout, err.Error())
  318. return err
  319. }
  320. // 启动进程期间绑定可能已在其他实例上变化,发布前必须重新确认。
  321. latest, err := m.repo.GetByID(ctx, enabled.ID)
  322. if err != nil {
  323. runtime.kill()
  324. return err
  325. }
  326. if !hasEnabledOpenAIBinding(latest.Bindings) || latest.BinarySHA256 != enabled.BinarySHA256 ||
  327. latest.ConfigEncrypted != enabled.ConfigEncrypted || bindingRollout(latest.Bindings) != rollout {
  328. runtime.kill()
  329. return nil
  330. }
  331. if latest.State == PluginStateStarting && !m.startingStateExpired(latest) {
  332. runtime.kill()
  333. return nil
  334. }
  335. if err := m.repo.MarkRuntimeHealthy(ctx, enabled.ID, enabled.BinarySHA256, enabled.ConfigEncrypted); err != nil {
  336. runtime.kill()
  337. if errors.Is(err, ErrPluginStateChanged) {
  338. return nil
  339. }
  340. return err
  341. }
  342. m.mu.Lock()
  343. stale := make([]*pluginRuntime, 0, len(m.runtimes))
  344. for id, candidate := range m.runtimes {
  345. if candidate != runtime {
  346. candidate.draining.Store(true)
  347. stale = append(stale, candidate)
  348. }
  349. if id != enabled.ID {
  350. delete(m.runtimes, id)
  351. }
  352. }
  353. m.runtimes[enabled.ID] = runtime
  354. m.route.Store(&pluginRoute{pluginID: enabled.ID, runtime: runtime, rolloutPercent: rollout})
  355. m.mu.Unlock()
  356. for _, candidate := range stale {
  357. candidate.drain(10 * time.Second)
  358. }
  359. return nil
  360. }
  361. func (m *PluginManager) startingStateExpired(installation *PluginInstallation) bool {
  362. if installation == nil || installation.UpdatedAt.IsZero() {
  363. return false
  364. }
  365. startTimeout := 15 * time.Second
  366. if m.cfg != nil && m.cfg.Plugins.StartTimeoutSeconds > 0 {
  367. startTimeout = time.Duration(m.cfg.Plugins.StartTimeoutSeconds) * time.Second
  368. }
  369. recoveryDelay := startTimeout + 45*time.Second
  370. if recoveryDelay < time.Minute {
  371. recoveryDelay = time.Minute
  372. }
  373. return time.Since(installation.UpdatedAt) > recoveryDelay
  374. }
  375. func (m *PluginManager) detachAllRuntimes() []*pluginRuntime {
  376. m.mu.Lock()
  377. defer m.mu.Unlock()
  378. runtimes := make([]*pluginRuntime, 0, len(m.runtimes))
  379. for id, runtime := range m.runtimes {
  380. runtime.draining.Store(true)
  381. runtimes = append(runtimes, runtime)
  382. delete(m.runtimes, id)
  383. }
  384. m.route.Store(nil)
  385. return runtimes
  386. }
  387. func (m *PluginManager) publishUnavailableRoute(pluginID int64, rollout int, message string) {
  388. m.mu.Lock()
  389. stale := make([]*pluginRuntime, 0, len(m.runtimes))
  390. for id, runtime := range m.runtimes {
  391. runtime.draining.Store(true)
  392. stale = append(stale, runtime)
  393. delete(m.runtimes, id)
  394. }
  395. m.route.Store(&pluginRoute{pluginID: pluginID, rolloutPercent: rollout, unavailable: message})
  396. m.mu.Unlock()
  397. for _, runtime := range stale {
  398. runtime.drain(10 * time.Second)
  399. }
  400. }
  401. func (m *PluginManager) ensureLocalInstallation(ctx context.Context, installation *PluginInstallation) (*PluginInstallation, error) {
  402. if installation == nil {
  403. return nil, errors.New("插件安装记录为空")
  404. }
  405. m.mu.Lock()
  406. local := m.localInstallations[installation.ID]
  407. m.mu.Unlock()
  408. if local != nil && local.BinarySHA256 == installation.BinarySHA256 && local.Version == installation.Version {
  409. if err := verifyLocalPluginBinary(local, m.installer.RootDir()); err == nil {
  410. return mergeLocalInstallation(local, installation), nil
  411. }
  412. }
  413. if err := verifyLocalPluginBinary(installation, m.installer.RootDir()); err == nil {
  414. local = mergeLocalInstallation(installation, installation)
  415. m.mu.Lock()
  416. m.localInstallations[installation.ID] = local
  417. m.mu.Unlock()
  418. return local, nil
  419. }
  420. artifact, err := m.repo.GetArtifact(ctx, installation.ID)
  421. if err != nil {
  422. return nil, fmt.Errorf("读取插件包原件: %w", err)
  423. }
  424. if len(artifact) == 0 {
  425. return nil, errors.New("插件包原件缺失,请重新上传插件")
  426. }
  427. restored, err := m.installer.Install(ctx, bytes.NewReader(artifact), installation.InstalledBy)
  428. if err != nil {
  429. return nil, fmt.Errorf("恢复并复验插件包: %w", err)
  430. }
  431. if !samePluginPackage(restored, installation) {
  432. cleanupErr := m.cleanupInstallationFiles(restored)
  433. return nil, errors.Join(errors.New("数据库插件包与安装记录不一致"), cleanupErr)
  434. }
  435. local = mergeLocalInstallation(restored, installation)
  436. m.mu.Lock()
  437. m.localInstallations[installation.ID] = local
  438. m.mu.Unlock()
  439. return local, nil
  440. }
  441. // cleanupStaleLocalInstallations 回收本实例缓存中已从数据库删除或已被新包替换的文件。
  442. // 数据库是跨实例的权威状态,本地目录不能因其他实例的卸载/升级永久残留。
  443. func (m *PluginManager) cleanupStaleLocalInstallations(installations []*PluginInstallation) {
  444. persisted := make(map[int64]*PluginInstallation, len(installations))
  445. for _, installation := range installations {
  446. if installation != nil {
  447. persisted[installation.ID] = installation
  448. }
  449. }
  450. m.mu.Lock()
  451. stale := make([]*PluginInstallation, 0)
  452. for id, local := range m.localInstallations {
  453. current := persisted[id]
  454. if current == nil || current.BinarySHA256 != local.BinarySHA256 || current.Version != local.Version {
  455. stale = append(stale, local)
  456. delete(m.localInstallations, id)
  457. }
  458. }
  459. m.mu.Unlock()
  460. for _, local := range stale {
  461. if err := m.cleanupInstallationFiles(local); err != nil {
  462. slog.Warn("plugin_stale_local_install_cleanup_failed", "plugin_id", local.ID, "error", err)
  463. }
  464. }
  465. }
  466. func mergeLocalInstallation(local, persisted *PluginInstallation) *PluginInstallation {
  467. merged := *persisted
  468. merged.ArtifactData = nil
  469. merged.ArtifactPath = local.ArtifactPath
  470. merged.InstallPath = local.InstallPath
  471. merged.BinaryPath = local.BinaryPath
  472. merged.Bindings = append([]PluginBinding(nil), persisted.Bindings...)
  473. return &merged
  474. }
  475. func samePluginPackage(local, persisted *PluginInstallation) bool {
  476. if local == nil || persisted == nil || local.PluginKey != persisted.PluginKey ||
  477. local.Version != persisted.Version || local.BinarySHA256 != persisted.BinarySHA256 {
  478. return false
  479. }
  480. localManifest, localErr := json.Marshal(local.Manifest)
  481. persistedManifest, persistedErr := json.Marshal(persisted.Manifest)
  482. return localErr == nil && persistedErr == nil && bytes.Equal(localManifest, persistedManifest)
  483. }
  484. func verifyLocalPluginBinary(installation *PluginInstallation, root string) error {
  485. if installation == nil {
  486. return errors.New("插件安装记录为空")
  487. }
  488. rootPath, err := filepath.Abs(root)
  489. if err != nil {
  490. return err
  491. }
  492. installPath, err := filepath.Abs(installation.InstallPath)
  493. if err != nil {
  494. return err
  495. }
  496. relative, err := filepath.Rel(rootPath, installPath)
  497. if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
  498. return errors.New("插件安装目录不在受管目录内")
  499. }
  500. runtimeEntry, ok := installation.Manifest.Runtimes[installation.Manifest.RuntimeKey()]
  501. if !ok {
  502. return errors.New("插件未声明当前平台运行时")
  503. }
  504. binaryPath, err := safePluginJoin(installPath, runtimeEntry.Path)
  505. if err != nil {
  506. return err
  507. }
  508. data, err := os.ReadFile(binaryPath)
  509. if err != nil {
  510. return err
  511. }
  512. digest := sha256.Sum256(data)
  513. if hex.EncodeToString(digest[:]) != installation.BinarySHA256 {
  514. return errors.New("本地插件二进制哈希不匹配")
  515. }
  516. installation.BinaryPath = binaryPath
  517. return nil
  518. }
  519. func (m *PluginManager) Enable(ctx context.Context, id int64, acceptUntested bool, rolloutPercent int) (*PluginInstallation, error) {
  520. m.operationMu.Lock()
  521. defer m.operationMu.Unlock()
  522. if rolloutPercent < 1 || rolloutPercent > 100 {
  523. return nil, errors.New("灰度比例必须在 1 到 100 之间")
  524. }
  525. installation, err := m.repo.GetByID(ctx, id)
  526. if err != nil {
  527. return nil, err
  528. }
  529. if active := m.route.Load(); active != nil && active.pluginID != id {
  530. return nil, errors.New("OpenAI OAuth 出站能力已有启用插件,请先停用当前插件")
  531. }
  532. if installation.State == PluginStateEnabled && hasEnabledOpenAIBinding(installation.Bindings) {
  533. installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo)
  534. m.mu.Lock()
  535. runtime := m.runtimes[id]
  536. m.mu.Unlock()
  537. installation.RuntimeHealthy = runtime != nil && !runtime.client.Exited()
  538. if installation.RuntimeHealthy {
  539. return installation, nil
  540. }
  541. }
  542. compatibility := EvaluatePluginCompatibility(installation.Manifest, m.hostInfo)
  543. if !compatibility.Compatible {
  544. stateErr := m.repo.UpdateState(ctx, id, PluginStateIncompatible, compatibility.Message, nil, installation.BinarySHA256, installation.State)
  545. return nil, errors.Join(errors.New(compatibility.Message), stateErr)
  546. }
  547. if !compatibility.Tested && !acceptUntested {
  548. return nil, errors.New("插件未声明已测试当前 Sub2API 版本,需要管理员确认后启用")
  549. }
  550. installation, err = m.ensureLocalInstallation(ctx, installation)
  551. if err != nil {
  552. return nil, err
  553. }
  554. originalBindings := append([]PluginBinding(nil), installation.Bindings...)
  555. for index := range installation.Bindings {
  556. installation.Bindings[index].Enabled = true
  557. installation.Bindings[index].RolloutPercent = rolloutPercent
  558. }
  559. if err := m.repo.BeginEnable(ctx, id, installation.BinarySHA256, installation.State); err != nil {
  560. return nil, err
  561. }
  562. runtime, err := m.prepareRuntime(ctx, installation, true)
  563. if err != nil {
  564. stateCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
  565. stateErr := m.repo.UpdateState(stateCtx, id, PluginStateError, err.Error(), nil, installation.BinarySHA256, PluginStateStarting)
  566. cancel()
  567. if hasEnabledOpenAIBinding(originalBindings) {
  568. m.route.Store(&pluginRoute{pluginID: id, rolloutPercent: bindingRollout(originalBindings), unavailable: err.Error()})
  569. }
  570. return nil, errors.Join(err, stateErr)
  571. }
  572. now := time.Now()
  573. if err := m.repo.UpdateBindingsAndState(ctx, id, installation.Bindings, PluginStateEnabled, "", &now, PluginStateStarting, installation.BinarySHA256); err != nil {
  574. runtime.kill()
  575. if errors.Is(err, ErrPluginStateChanged) {
  576. return nil, err
  577. }
  578. stateCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
  579. stateErr := m.repo.UpdateState(stateCtx, id, PluginStateError, err.Error(), nil, installation.BinarySHA256, PluginStateStarting)
  580. cancel()
  581. return nil, errors.Join(err, stateErr)
  582. }
  583. m.mu.Lock()
  584. m.publishRuntimeLocked(installation, runtime)
  585. m.mu.Unlock()
  586. result, err := m.repo.GetByID(ctx, id)
  587. if err != nil {
  588. return nil, err
  589. }
  590. result.Compatibility = compatibility
  591. result.RuntimeHealthy = true
  592. result.RuntimeMessage = "插件进程运行中"
  593. return result, nil
  594. }
  595. func (m *PluginManager) Disable(ctx context.Context, id int64) (*PluginInstallation, error) {
  596. m.operationMu.Lock()
  597. defer m.operationMu.Unlock()
  598. m.mu.Lock()
  599. installation, err := m.repo.GetByID(ctx, id)
  600. if err != nil {
  601. m.mu.Unlock()
  602. return nil, err
  603. }
  604. for index := range installation.Bindings {
  605. installation.Bindings[index].Enabled = false
  606. }
  607. if err := m.repo.UpdateBindingsAndState(ctx, id, installation.Bindings, PluginStateDisabled, "", nil, "", installation.BinarySHA256); err != nil {
  608. m.mu.Unlock()
  609. return nil, err
  610. }
  611. runtime := m.removeRuntimeLocked(id)
  612. m.mu.Unlock()
  613. if runtime != nil {
  614. runtime.drain(10 * time.Second)
  615. }
  616. return m.Get(ctx, id)
  617. }
  618. func (m *PluginManager) Delete(ctx context.Context, id int64) error {
  619. m.operationMu.Lock()
  620. defer m.operationMu.Unlock()
  621. m.mu.Lock()
  622. installation, err := m.repo.GetByID(ctx, id)
  623. if err != nil {
  624. m.mu.Unlock()
  625. return err
  626. }
  627. if installation.State == PluginStateEnabled || hasEnabledOpenAIBinding(installation.Bindings) {
  628. m.mu.Unlock()
  629. return errors.New("请先停用插件,再执行卸载")
  630. }
  631. if err := m.repo.Delete(ctx, id, installation.BinarySHA256); err != nil {
  632. m.mu.Unlock()
  633. return err
  634. }
  635. runtime := m.removeRuntimeLocked(id)
  636. local := m.localInstallations[id]
  637. delete(m.localInstallations, id)
  638. m.mu.Unlock()
  639. if runtime != nil {
  640. runtime.drain(10 * time.Second)
  641. }
  642. cleanupErr := m.cleanupInstallationFiles(installation)
  643. if local != nil && (local.InstallPath != installation.InstallPath || local.ArtifactPath != installation.ArtifactPath) {
  644. cleanupErr = errors.Join(cleanupErr, m.cleanupInstallationFiles(local))
  645. }
  646. return cleanupErr
  647. }
  648. func (m *PluginManager) GetConfig(ctx context.Context, id int64) (json.RawMessage, error) {
  649. installation, err := m.repo.GetByID(ctx, id)
  650. if err != nil {
  651. return nil, err
  652. }
  653. return m.decryptConfig(installation)
  654. }
  655. func (m *PluginManager) SaveConfig(ctx context.Context, id int64, raw json.RawMessage) (json.RawMessage, error) {
  656. m.operationMu.Lock()
  657. defer m.operationMu.Unlock()
  658. if len(raw) == 0 || len(raw) > pluginConfigMaxBytes || !json.Valid(raw) {
  659. return nil, errors.New("插件配置必须是有效且大小受限的 JSON")
  660. }
  661. installation, err := m.repo.GetByID(ctx, id)
  662. if err != nil {
  663. return nil, err
  664. }
  665. previousConfig, err := m.decryptConfig(installation)
  666. if err != nil {
  667. return nil, err
  668. }
  669. var normalized any
  670. if err := json.Unmarshal(raw, &normalized); err != nil {
  671. return nil, err
  672. }
  673. if normalized == nil {
  674. return nil, errors.New("插件配置 JSON 根节点必须是对象")
  675. }
  676. if _, ok := normalized.(map[string]any); !ok {
  677. return nil, errors.New("插件配置 JSON 根节点必须是对象")
  678. }
  679. canonical, err := json.Marshal(normalized)
  680. if err != nil {
  681. return nil, err
  682. }
  683. m.mu.Lock()
  684. runtime := m.runtimes[id]
  685. m.mu.Unlock()
  686. temporary := false
  687. if runtime == nil {
  688. installation, err = m.ensureLocalInstallation(ctx, installation)
  689. if err != nil {
  690. return nil, err
  691. }
  692. runtime, err = m.newRuntime(ctx, installation)
  693. if err != nil {
  694. return nil, err
  695. }
  696. temporary = true
  697. defer runtime.kill()
  698. }
  699. if runtime != nil {
  700. applyCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
  701. canonical, err = runtime.validateAndApplyNormalizedConfig(applyCtx, canonical)
  702. cancel()
  703. if err != nil {
  704. return nil, err
  705. }
  706. }
  707. encrypted, err := m.encryptor.Encrypt(string(canonical))
  708. if err != nil {
  709. if !temporary {
  710. err = errors.Join(err, m.restoreRuntimeConfig(id, runtime, previousConfig))
  711. }
  712. return nil, fmt.Errorf("加密插件配置: %w", err)
  713. }
  714. if err := m.repo.UpdateConfig(ctx, id, encrypted, installation.BinarySHA256); err != nil {
  715. if !temporary {
  716. err = errors.Join(err, m.restoreRuntimeConfig(id, runtime, previousConfig))
  717. }
  718. return nil, err
  719. }
  720. if !temporary {
  721. runtime.installation.ConfigEncrypted = encrypted
  722. }
  723. return canonical, nil
  724. }
  725. func (m *PluginManager) restoreRuntimeConfig(id int64, runtime *pluginRuntime, previous json.RawMessage) error {
  726. if runtime == nil {
  727. return nil
  728. }
  729. rollbackCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
  730. defer cancel()
  731. if err := runtime.validateAndApplyConfig(rollbackCtx, previous); err != nil {
  732. route := m.route.Load()
  733. if route != nil && route.pluginID == id && route.runtime == runtime {
  734. stateErr := m.markRuntimeUnavailable(route, "插件配置回滚失败: "+err.Error())
  735. return errors.Join(err, stateErr)
  736. }
  737. return err
  738. }
  739. return nil
  740. }
  741. func (m *PluginManager) Test(ctx context.Context, id int64) (*pluginv1.TestConfigResponse, error) {
  742. m.operationMu.Lock()
  743. defer m.operationMu.Unlock()
  744. installation, err := m.repo.GetByID(ctx, id)
  745. if err != nil {
  746. return nil, err
  747. }
  748. configJSON, err := m.decryptConfig(installation)
  749. if err != nil {
  750. return nil, err
  751. }
  752. m.mu.Lock()
  753. runtime := m.runtimes[id]
  754. m.mu.Unlock()
  755. temporary := false
  756. if runtime == nil {
  757. installation, err = m.ensureLocalInstallation(ctx, installation)
  758. if err != nil {
  759. return nil, err
  760. }
  761. runtime, err = m.newRuntime(ctx, installation)
  762. if err != nil {
  763. return nil, err
  764. }
  765. temporary = true
  766. }
  767. if temporary {
  768. defer runtime.kill()
  769. }
  770. testCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
  771. defer cancel()
  772. if err := runtime.validateAndApplyConfig(testCtx, configJSON); err != nil {
  773. return nil, err
  774. }
  775. return runtime.api.TestConfig(testCtx, &pluginv1.TestConfigRequest{ConfigJson: configJSON})
  776. }
  777. type pluginUIAssetClaims struct {
  778. Version int `json:"version"`
  779. PluginID int64 `json:"plugin_id"`
  780. Expires int64 `json:"expires"`
  781. }
  782. // CreateUIAssetToken 创建可跨实例校验的短时能力令牌,令牌不包含管理员凭据。
  783. func (m *PluginManager) CreateUIAssetToken(ctx context.Context, id int64, ttl time.Duration) (string, time.Time, error) {
  784. if ttl <= 0 || ttl > time.Hour {
  785. return "", time.Time{}, errors.New("插件 UI 会话有效期无效")
  786. }
  787. if _, err := m.repo.GetByID(ctx, id); err != nil {
  788. return "", time.Time{}, err
  789. }
  790. expires := time.Now().Add(ttl)
  791. raw, err := json.Marshal(pluginUIAssetClaims{Version: 1, PluginID: id, Expires: expires.Unix()})
  792. if err != nil {
  793. return "", time.Time{}, err
  794. }
  795. // 加用途前缀,避免复用同一 AES-GCM 密钥的其他密文被当作 UI 能力令牌。
  796. encrypted, err := m.encryptor.Encrypt(pluginUITokenPrefix + string(raw))
  797. if err != nil {
  798. return "", time.Time{}, fmt.Errorf("加密插件 UI 会话: %w", err)
  799. }
  800. return base64.RawURLEncoding.EncodeToString([]byte(encrypted)), expires, nil
  801. }
  802. func (m *PluginManager) ResolveUIAssetToken(token string) (int64, error) {
  803. if len(token) == 0 || len(token) > 4096 {
  804. return 0, errors.New("插件 UI 会话无效")
  805. }
  806. encrypted, err := base64.RawURLEncoding.DecodeString(token)
  807. if err != nil {
  808. return 0, errors.New("插件 UI 会话无效")
  809. }
  810. plaintext, err := m.encryptor.Decrypt(string(encrypted))
  811. if err != nil {
  812. return 0, errors.New("插件 UI 会话无效")
  813. }
  814. plaintext, ok := strings.CutPrefix(plaintext, pluginUITokenPrefix)
  815. if !ok {
  816. return 0, errors.New("插件 UI 会话无效")
  817. }
  818. var claims pluginUIAssetClaims
  819. decoder := json.NewDecoder(strings.NewReader(plaintext))
  820. decoder.DisallowUnknownFields()
  821. if err := decoder.Decode(&claims); err != nil || claims.Version != 1 || claims.PluginID <= 0 {
  822. return 0, errors.New("插件 UI 会话无效")
  823. }
  824. if err := decoder.Decode(&struct{}{}); err != io.EOF {
  825. return 0, errors.New("插件 UI 会话无效")
  826. }
  827. now := time.Now().Unix()
  828. if now >= claims.Expires {
  829. return 0, errors.New("插件 UI 会话已过期")
  830. }
  831. if claims.Expires > now+int64(time.Hour/time.Second) {
  832. return 0, errors.New("插件 UI 会话无效")
  833. }
  834. return claims.PluginID, nil
  835. }
  836. func (m *PluginManager) ReadUIAsset(ctx context.Context, id int64, relative string) ([]byte, string, error) {
  837. m.operationMu.Lock()
  838. defer m.operationMu.Unlock()
  839. installation, err := m.repo.GetByID(ctx, id)
  840. if err != nil {
  841. return nil, "", err
  842. }
  843. installation, err = m.ensureLocalInstallation(ctx, installation)
  844. if err != nil {
  845. return nil, "", err
  846. }
  847. path := strings.TrimPrefix(strings.ReplaceAll(relative, "\\", "/"), "/")
  848. if path == "" || path == "index.html" {
  849. path = installation.Manifest.UI.Entrypoint
  850. } else {
  851. path = "ui/" + path
  852. }
  853. if _, declared := installation.Manifest.Files[path]; !declared || !strings.HasPrefix(path, "ui/") {
  854. return nil, "", os.ErrNotExist
  855. }
  856. fullPath, err := safePluginJoin(installation.InstallPath, path)
  857. if err != nil {
  858. return nil, "", err
  859. }
  860. file, err := os.Open(fullPath)
  861. if err != nil {
  862. return nil, "", err
  863. }
  864. defer func() { _ = file.Close() }()
  865. data, err := io.ReadAll(io.LimitReader(file, pluginUIAssetMaxBytes+1))
  866. if err != nil {
  867. return nil, "", err
  868. }
  869. if len(data) > pluginUIAssetMaxBytes {
  870. return nil, "", errors.New("插件 UI 资源超过大小限制")
  871. }
  872. digest := sha256.Sum256(data)
  873. if hex.EncodeToString(digest[:]) != installation.Manifest.Files[path] {
  874. return nil, "", errors.New("插件 UI 资源哈希不匹配")
  875. }
  876. return data, path, nil
  877. }
  878. func (m *PluginManager) RoundTripOpenAIOAuth(ctx context.Context, request *http.Request, proxyURL string, account *Account) (*http.Response, bool, error) {
  879. if !m.ShouldRouteOpenAIOAuth(account) {
  880. return nil, false, nil
  881. }
  882. route := m.route.Load()
  883. if route == nil {
  884. return nil, false, nil
  885. }
  886. if route.runtime == nil {
  887. return nil, true, fmt.Errorf("OpenAI OAuth 插件不可用: %s", route.unavailable)
  888. }
  889. if route.runtime.client.Exited() {
  890. runtimeErr := errors.New("OpenAI OAuth 插件进程已退出")
  891. if stateErr := m.markRuntimeUnavailable(route, runtimeErr.Error()); stateErr != nil {
  892. return nil, true, errors.Join(runtimeErr, stateErr)
  893. }
  894. return nil, true, runtimeErr
  895. }
  896. if !route.runtime.beginRequest() {
  897. return nil, true, errors.New("OpenAI OAuth 插件正在停止")
  898. }
  899. response, err := route.runtime.roundTrip(ctx, request, proxyURL, account)
  900. if err != nil {
  901. route.runtime.finishRequest()
  902. if route.runtime.client.Exited() {
  903. if stateErr := m.markRuntimeUnavailable(route, err.Error()); stateErr != nil {
  904. err = errors.Join(err, stateErr)
  905. }
  906. }
  907. return nil, true, err
  908. }
  909. return response, true, nil
  910. }
  911. // ShouldRouteOpenAIOAuth 判断该账号是否命中当前 OpenAI OAuth 插件绑定。
  912. // WebSocket 入口用它把命中的账号切换到 HTTP Bridge,避免绕过 v1 HTTP 插件协议。
  913. func (m *PluginManager) ShouldRouteOpenAIOAuth(account *Account) bool {
  914. if m == nil || account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth {
  915. return false
  916. }
  917. route := m.route.Load()
  918. return route != nil && route.rolloutPercent > 0 && int(stablePluginBucket(account.ID)) < route.rolloutPercent
  919. }
  920. func (m *PluginManager) markRuntimeUnavailable(failedRoute *pluginRoute, message string) error {
  921. m.mu.Lock()
  922. current := m.route.Load()
  923. if current != failedRoute {
  924. m.mu.Unlock()
  925. return nil
  926. }
  927. if failedRoute.runtime != nil {
  928. failedRoute.runtime.kill()
  929. }
  930. delete(m.runtimes, failedRoute.pluginID)
  931. m.route.Store(&pluginRoute{
  932. pluginID: failedRoute.pluginID,
  933. rolloutPercent: failedRoute.rolloutPercent,
  934. unavailable: message,
  935. })
  936. m.mu.Unlock()
  937. return nil
  938. }
  939. func (m *PluginManager) prepareRuntime(ctx context.Context, installation *PluginInstallation, validateConfig bool) (*pluginRuntime, error) {
  940. runtime, err := m.newRuntime(ctx, installation)
  941. if err != nil {
  942. return nil, err
  943. }
  944. configJSON, err := m.decryptConfig(installation)
  945. if err == nil && validateConfig {
  946. applyCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
  947. err = runtime.validateAndApplyConfig(applyCtx, configJSON)
  948. cancel()
  949. }
  950. if err != nil {
  951. runtime.kill()
  952. return nil, err
  953. }
  954. return runtime, nil
  955. }
  956. func (m *PluginManager) publishRuntimeLocked(installation *PluginInstallation, runtime *pluginRuntime) {
  957. if old := m.runtimes[installation.ID]; old != nil {
  958. old.kill()
  959. }
  960. m.runtimes[installation.ID] = runtime
  961. m.route.Store(&pluginRoute{
  962. pluginID: installation.ID,
  963. runtime: runtime,
  964. rolloutPercent: bindingRollout(installation.Bindings),
  965. })
  966. }
  967. func (m *PluginManager) newRuntime(ctx context.Context, installation *PluginInstallation) (*pluginRuntime, error) {
  968. socketDir := filepath.Join(m.installer.RootDir(), "runtime")
  969. if err := os.MkdirAll(socketDir, 0o700); err != nil {
  970. return nil, err
  971. }
  972. timeout := time.Duration(m.cfg.Plugins.StartTimeoutSeconds) * time.Second
  973. return startPluginRuntime(ctx, installation, timeout, socketDir)
  974. }
  975. func (m *PluginManager) removeRuntimeLocked(id int64) *pluginRuntime {
  976. runtime := m.runtimes[id]
  977. delete(m.runtimes, id)
  978. if route := m.route.Load(); route != nil && route.pluginID == id {
  979. m.route.Store(nil)
  980. }
  981. return runtime
  982. }
  983. func (m *PluginManager) decryptConfig(installation *PluginInstallation) (json.RawMessage, error) {
  984. if installation == nil || strings.TrimSpace(installation.ConfigEncrypted) == "" {
  985. return json.RawMessage(`{}`), nil
  986. }
  987. plaintext, err := m.encryptor.Decrypt(installation.ConfigEncrypted)
  988. if err != nil {
  989. return nil, fmt.Errorf("解密插件配置: %w", err)
  990. }
  991. if !json.Valid([]byte(plaintext)) {
  992. return nil, errors.New("已保存的插件配置不是有效 JSON")
  993. }
  994. return json.RawMessage(plaintext), nil
  995. }
  996. func (m *PluginManager) removeManagedPath(target string) error {
  997. if strings.TrimSpace(target) == "" {
  998. return nil
  999. }
  1000. root, err := filepath.Abs(m.installer.RootDir())
  1001. if err != nil {
  1002. return err
  1003. }
  1004. absTarget, err := filepath.Abs(target)
  1005. if err != nil {
  1006. return err
  1007. }
  1008. relative, err := filepath.Rel(root, absTarget)
  1009. if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
  1010. return errors.New("拒绝删除插件根目录之外的路径")
  1011. }
  1012. return os.RemoveAll(absTarget)
  1013. }
  1014. func hasEnabledOpenAIBinding(bindings []PluginBinding) bool {
  1015. for _, binding := range bindings {
  1016. if binding.Enabled && binding.Capability == PluginCapabilityOpenAIOAuthOutbound &&
  1017. binding.Platform == PlatformOpenAI && binding.AccountType == AccountTypeOAuth {
  1018. return true
  1019. }
  1020. }
  1021. return false
  1022. }
  1023. func bindingRollout(bindings []PluginBinding) int {
  1024. for _, binding := range bindings {
  1025. if binding.Capability == PluginCapabilityOpenAIOAuthOutbound {
  1026. return binding.RolloutPercent
  1027. }
  1028. }
  1029. return 100
  1030. }
  1031. func stablePluginBucket(accountID int64) uint64 {
  1032. value := uint64(accountID)
  1033. value ^= value >> 33
  1034. value *= 0xff51afd7ed558ccd
  1035. value ^= value >> 33
  1036. return value % 100
  1037. }