prompt_config_store.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. package securityaudit
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "strconv"
  9. "strings"
  10. "sync"
  11. "sync/atomic"
  12. "time"
  13. "github.com/Wei-Shaw/sub2api/internal/config"
  14. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  15. "github.com/Wei-Shaw/sub2api/internal/service"
  16. "github.com/redis/go-redis/v9"
  17. )
  18. type activeConfigSnapshot struct {
  19. storage storageConfig
  20. active ActiveConfig
  21. loadedAt time.Time
  22. }
  23. type ConfigManager struct {
  24. db *sql.DB
  25. settings service.SettingRepository
  26. redis *redis.Client
  27. encryptor SecretEncryptor
  28. clock Clock
  29. // encryptionKeyConfigured mirrors cfg.Totp.EncryptionKeyConfigured. With an
  30. // auto-generated (per-boot) key, newly saved endpoint tokens would become
  31. // undecryptable after the next restart, so Save rejects them (issue #4887).
  32. encryptionKeyConfigured bool
  33. snapshot atomic.Pointer[activeConfigSnapshot]
  34. expected atomic.Int64
  35. // expectedBlocking records the last storage intent that could be decoded,
  36. // independently of whether endpoint credentials or the full config could be
  37. // activated. A config version alone cannot distinguish async from blocking.
  38. expectedBlocking atomic.Bool
  39. // configUntrusted is set when a load/reload fails before a trustworthy
  40. // snapshot is installed. Combined with expectedBlocking, EffectiveMode
  41. // fails closed so a persisted blocking policy cannot be silently skipped
  42. // after startup or invalidation errors. Without blocking intent, untrusted
  43. // alone must not force ModeBlocking—Prompt Audit is default-off and must
  44. // not take the gateway down for every API request (see issue #4560).
  45. configUntrusted atomic.Bool
  46. stateMu sync.RWMutex
  47. lastLoadError string
  48. lastErrorAt *time.Time
  49. lifecycleMu sync.Mutex
  50. cancel context.CancelFunc
  51. wg sync.WaitGroup
  52. }
  53. func NewConfigManager(db *sql.DB, settings service.SettingRepository, redisClient *redis.Client, encryptor service.SecretEncryptor, cfg *config.Config) *ConfigManager {
  54. return &ConfigManager{
  55. db: db, settings: settings, redis: redisClient, encryptor: encryptor, clock: realClock{},
  56. encryptionKeyConfigured: cfg != nil && cfg.Totp.EncryptionKeyConfigured,
  57. }
  58. }
  59. func (m *ConfigManager) Start(ctx context.Context) error {
  60. if m == nil {
  61. return errors.New("prompt audit config manager unavailable")
  62. }
  63. m.lifecycleMu.Lock()
  64. if m.cancel != nil {
  65. m.lifecycleMu.Unlock()
  66. return nil
  67. }
  68. runCtx, cancel := context.WithCancel(ctx)
  69. m.cancel = cancel
  70. m.lifecycleMu.Unlock()
  71. loadErr := m.Reload(runCtx)
  72. if loadErr != nil {
  73. m.markConfigUntrusted()
  74. }
  75. m.wg.Add(1)
  76. go m.refreshLoop(runCtx)
  77. if m.redis != nil {
  78. m.wg.Add(1)
  79. go m.subscribeLoop(runCtx)
  80. }
  81. return loadErr
  82. }
  83. func (m *ConfigManager) Shutdown(_ context.Context) error {
  84. if m == nil {
  85. return nil
  86. }
  87. m.lifecycleMu.Lock()
  88. cancel := m.cancel
  89. m.cancel = nil
  90. m.lifecycleMu.Unlock()
  91. if cancel != nil {
  92. cancel()
  93. }
  94. m.wg.Wait()
  95. return nil
  96. }
  97. func (m *ConfigManager) Reload(ctx context.Context) error {
  98. if m == nil || m.settings == nil {
  99. m.markUntrustedIfNoActiveSnapshot()
  100. return errors.New("prompt audit setting repository unavailable")
  101. }
  102. values, err := m.settings.GetMultiple(ctx, []string{SettingKeyPromptAuditConfig, SettingKeyRiskControl})
  103. if err != nil {
  104. m.recordLoadError(err)
  105. m.markUntrustedIfNoActiveSnapshot()
  106. return err
  107. }
  108. m.observeExpectedState(values[SettingKeyPromptAuditConfig], values[SettingKeyRiskControl] == "true")
  109. storage, err := ParseStorageConfig(values[SettingKeyPromptAuditConfig])
  110. if err != nil {
  111. m.recordLoadError(err)
  112. m.markUntrustedIfNoActiveSnapshot()
  113. return err
  114. }
  115. m.expected.Store(storage.ConfigVersion)
  116. m.expectedBlocking.Store(values[SettingKeyRiskControl] == "true" && storage.Enabled && storage.BlockingEnabled)
  117. active, err := ActiveFromStorage(storage, values[SettingKeyRiskControl] == "true", m.encryptor)
  118. if err != nil {
  119. m.recordLoadError(err)
  120. // expectedBlocking may already require fail-closed via BlockingActivationDegraded.
  121. m.markUntrustedIfNoActiveSnapshot()
  122. return err
  123. }
  124. now := m.clock.Now()
  125. previous := m.snapshot.Load()
  126. m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(storage), active: cloneActiveConfig(active), loadedAt: now})
  127. m.configUntrusted.Store(false)
  128. recovered := m.clearLoadError()
  129. m.logInvalidTokenEndpoints(previous, active)
  130. // refreshLoop calls Reload every 5s, so logging every successful load turns
  131. // config_loaded into a heartbeat that buries real config changes.
  132. if recovered || shouldLogConfigLoaded(previous, storage, active) {
  133. LogInfo(EventConfigLoaded, map[string]any{
  134. "config_version": storage.ConfigVersion, "status": "loaded",
  135. })
  136. }
  137. return nil
  138. }
  139. // shouldLogConfigLoaded reports whether a successful reload carries news: the
  140. // first snapshot, a new config version (every admin save bumps it under the
  141. // advisory lock in UpdateConfig) or a flip of the global risk control gate,
  142. // which lives in its own setting and so leaves the version untouched.
  143. func shouldLogConfigLoaded(previous *activeConfigSnapshot, storage storageConfig, active ActiveConfig) bool {
  144. return previous == nil ||
  145. previous.storage.ConfigVersion != storage.ConfigVersion ||
  146. previous.active.RiskControlEnabled != active.RiskControlEnabled
  147. }
  148. // logInvalidTokenEndpoints warns once per change (not on every 5s refresh)
  149. // when stored endpoint tokens cannot be decrypted with the current key.
  150. func (m *ConfigManager) logInvalidTokenEndpoints(previous *activeConfigSnapshot, active ActiveConfig) {
  151. invalid := active.InvalidTokenEndpointIDs()
  152. if len(invalid) == 0 {
  153. return
  154. }
  155. if previous != nil {
  156. prior := previous.active.InvalidTokenEndpointIDs()
  157. if len(prior) == len(invalid) {
  158. same := true
  159. for i := range invalid {
  160. if prior[i] != invalid[i] {
  161. same = false
  162. break
  163. }
  164. }
  165. if same && previous.active.ConfigVersion == active.ConfigVersion {
  166. return
  167. }
  168. }
  169. }
  170. LogWarn(EventConfigTokenInvalid, map[string]any{
  171. "config_version": active.ConfigVersion, "status": "degraded",
  172. "error_code": "endpoint_token_undecryptable", "guard_endpoint_id": strings.Join(invalid, ","),
  173. })
  174. }
  175. func (m *ConfigManager) Active() (ActiveConfig, bool) {
  176. if m == nil {
  177. return ActiveConfig{}, false
  178. }
  179. snapshot := m.snapshot.Load()
  180. if snapshot == nil {
  181. return ActiveConfig{}, false
  182. }
  183. return cloneActiveConfig(snapshot.active), true
  184. }
  185. func (m *ConfigManager) BlockingActivationDegraded() bool {
  186. if m == nil {
  187. return false
  188. }
  189. // Fail closed only when storage intent requires blocking. Untrusted config
  190. // without blocking intent must remain ModeOff so administrators can still
  191. // operate the gateway and turn Prompt Audit off after a failed reload.
  192. if !m.expectedBlocking.Load() {
  193. return false
  194. }
  195. if m.configUntrusted.Load() {
  196. return true
  197. }
  198. active, ok := m.Active()
  199. if !ok {
  200. return true
  201. }
  202. // A still-active weaker snapshot after a failed blocking activation must not
  203. // keep serving allow decisions under the old off/async mode.
  204. return active.EffectiveMode() != ModeBlocking
  205. }
  206. func (m *ConfigManager) EffectiveMode() Mode {
  207. if m != nil && m.BlockingActivationDegraded() {
  208. return ModeBlocking
  209. }
  210. active, ok := m.Active()
  211. if !ok {
  212. return ModeOff
  213. }
  214. return active.EffectiveMode()
  215. }
  216. func (m *ConfigManager) markConfigUntrusted() {
  217. if m == nil {
  218. return
  219. }
  220. m.configUntrusted.Store(true)
  221. }
  222. func (m *ConfigManager) markUntrustedIfNoActiveSnapshot() {
  223. if m == nil {
  224. return
  225. }
  226. if _, ok := m.Active(); !ok {
  227. m.markConfigUntrusted()
  228. }
  229. }
  230. func (m *ConfigManager) Public() (PublicConfig, error) {
  231. if m == nil {
  232. return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用")
  233. }
  234. snapshot := m.snapshot.Load()
  235. if snapshot == nil {
  236. return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用")
  237. }
  238. return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled, snapshot.active.InvalidTokenEndpointIDs()), nil
  239. }
  240. func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) {
  241. if m == nil || m.db == nil || m.encryptor == nil {
  242. return PublicConfig{}, errors.New("prompt audit config persistence unavailable")
  243. }
  244. if req.ExpectedConfigVersion < 1 {
  245. return PublicConfig{}, infraerrors.BadRequest("prompt_audit_expected_config_version_required", "必须提供有效的配置版本")
  246. }
  247. tx, err := m.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
  248. if err != nil {
  249. return PublicConfig{}, err
  250. }
  251. defer func() { _ = tx.Rollback() }()
  252. if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, promptAuditConfigLockKey); err != nil {
  253. return PublicConfig{}, err
  254. }
  255. current := DefaultStorageConfig()
  256. var raw string
  257. err = tx.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=$1 FOR UPDATE`, SettingKeyPromptAuditConfig).Scan(&raw)
  258. if err != nil && !errors.Is(err, sql.ErrNoRows) {
  259. return PublicConfig{}, err
  260. }
  261. if err == nil {
  262. current, err = ParseStorageConfig(raw)
  263. if err != nil {
  264. return PublicConfig{}, err
  265. }
  266. }
  267. if current.ConfigVersion != req.ExpectedConfigVersion {
  268. return PublicConfig{}, infraerrors.Conflict(ErrorCodeConfigConflict, "提示词审计配置已被其他管理员更新")
  269. }
  270. next, err := m.buildNextStorage(current, req, actorID)
  271. if err != nil {
  272. return PublicConfig{}, err
  273. }
  274. next.ConfigVersion = current.ConfigVersion + 1
  275. next.UpdatedAt = m.clock.Now()
  276. next.UpdatedBy = actorID
  277. next.ChangeSummary = changeSummary(next)
  278. rawNext, err := json.Marshal(next)
  279. if err != nil {
  280. return PublicConfig{}, err
  281. }
  282. if _, err := tx.ExecContext(ctx, `
  283. INSERT INTO settings (key,value,updated_at) VALUES ($1,$2,NOW())
  284. ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=EXCLUDED.updated_at`,
  285. SettingKeyPromptAuditConfig, string(rawNext)); err != nil {
  286. return PublicConfig{}, err
  287. }
  288. if err := tx.Commit(); err != nil {
  289. return PublicConfig{}, err
  290. }
  291. // Install the snapshot with the current global gate, not merely the value
  292. // cached when this process last reloaded Prompt Audit configuration.
  293. riskControlEnabled := m.currentRiskControlEnabled()
  294. if values, getErr := m.settings.GetMultiple(ctx, []string{SettingKeyRiskControl}); getErr == nil {
  295. riskControlEnabled = values[SettingKeyRiskControl] == "true"
  296. }
  297. active, err := ActiveFromStorage(next, riskControlEnabled, m.encryptor)
  298. if err != nil {
  299. return PublicConfig{}, err
  300. }
  301. m.expected.Store(next.ConfigVersion)
  302. m.expectedBlocking.Store(active.RiskControlEnabled && next.Enabled && next.BlockingEnabled)
  303. previous := m.snapshot.Load()
  304. m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(next), active: cloneActiveConfig(active), loadedAt: m.clock.Now()})
  305. // A successful admin save installs a trustworthy snapshot; clear any prior
  306. // fail-closed degradation so disabling audit actually takes effect.
  307. m.configUntrusted.Store(false)
  308. m.clearLoadError()
  309. m.logInvalidTokenEndpoints(previous, active)
  310. LogInfo(EventConfigUpdated, map[string]any{
  311. "config_version": next.ConfigVersion, "status": "updated",
  312. })
  313. if m.redis != nil {
  314. if err := m.redis.Publish(ctx, ConfigInvalidationChannel, strconv.FormatInt(next.ConfigVersion, 10)).Err(); err != nil {
  315. LogWarn(EventConfigReloadDegraded, map[string]any{
  316. "config_version": next.ConfigVersion, "status": "degraded", "error_code": "config_invalidation_publish_failed",
  317. })
  318. }
  319. }
  320. return PublicFromStorage(next, active.RiskControlEnabled, active.InvalidTokenEndpointIDs()), nil
  321. }
  322. func (m *ConfigManager) buildNextStorage(current storageConfig, req UpdateConfigRequest, actorID int64) (storageConfig, error) {
  323. if err := validateUpdateConfigRequest(req); err != nil {
  324. return storageConfig{}, err
  325. }
  326. currentByID := make(map[string]StorageEndpoint, len(current.Endpoints))
  327. for _, endpoint := range current.Endpoints {
  328. currentByID[endpoint.ID] = endpoint
  329. }
  330. next := storageConfig{
  331. Enabled: req.Enabled, BlockingEnabled: req.BlockingEnabled, BlockingLatestTurnOnly: req.BlockingLatestTurnOnly, StorePassEvents: req.StorePassEvents,
  332. Strategy: strings.TrimSpace(req.Strategy), WorkerCount: req.WorkerCount,
  333. QueueCapacity: req.QueueCapacity, Scanners: append([]string(nil), req.Scanners...),
  334. AllGroups: req.AllGroups, GroupIDs: append([]int64(nil), req.GroupIDs...),
  335. ConfigVersion: current.ConfigVersion, UpdatedBy: actorID,
  336. Endpoints: make([]StorageEndpoint, 0, len(req.Endpoints)),
  337. }
  338. for _, endpoint := range req.Endpoints {
  339. baseURL, err := NormalizeBaseURL(endpoint.BaseURL)
  340. if err != nil {
  341. return storageConfig{}, err
  342. }
  343. stored := StorageEndpoint{
  344. ID: strings.TrimSpace(endpoint.ID), Name: strings.TrimSpace(endpoint.Name),
  345. Protocol: strings.TrimSpace(endpoint.Protocol), BaseURL: baseURL, Model: strings.TrimSpace(endpoint.Model),
  346. TimeoutMS: endpoint.TimeoutMS, InputLimit: endpoint.InputLimit, Enabled: endpoint.Enabled,
  347. }
  348. old, hadOld := currentByID[stored.ID]
  349. switch {
  350. case endpoint.ClearToken:
  351. stored.TokenCiphertext = ""
  352. case strings.TrimSpace(endpoint.Token) != "":
  353. if !m.encryptionKeyConfigured {
  354. return storageConfig{}, infraerrors.BadRequest(ErrorCodeEncryptionKeyRequired,
  355. "未配置固定加密密钥,审计节点 Token 将在服务重启后失效。请先设置 TOTP_ENCRYPTION_KEY 环境变量(64 位十六进制)并重启服务")
  356. }
  357. ciphertext, err := m.encryptor.Encrypt(strings.TrimSpace(endpoint.Token))
  358. if err != nil {
  359. return storageConfig{}, fmt.Errorf("encrypt prompt audit endpoint token: %w", err)
  360. }
  361. stored.TokenCiphertext = ciphertext
  362. case hadOld:
  363. stored.TokenCiphertext = old.TokenCiphertext
  364. }
  365. next.Endpoints = append(next.Endpoints, stored)
  366. }
  367. normalizeStorageConfig(&next)
  368. if err := validateStorageConfig(next); err != nil {
  369. return storageConfig{}, err
  370. }
  371. return next, nil
  372. }
  373. func (m *ConfigManager) RuntimeState() (expected int64, active int64, loadedAt *time.Time, loadError string) {
  374. if m == nil {
  375. return 1, 0, nil, "config_manager_unavailable"
  376. }
  377. expected = m.expected.Load()
  378. if expected < 1 {
  379. expected = 1
  380. }
  381. if snapshot := m.snapshot.Load(); snapshot != nil {
  382. active = snapshot.active.ConfigVersion
  383. value := snapshot.loadedAt
  384. loadedAt = &value
  385. }
  386. m.stateMu.RLock()
  387. loadError = m.lastLoadError
  388. m.stateMu.RUnlock()
  389. return
  390. }
  391. func (m *ConfigManager) Encrypt(value string) (string, error) { return m.encryptor.Encrypt(value) }
  392. func (m *ConfigManager) Decrypt(value string) (string, error) { return m.encryptor.Decrypt(value) }
  393. func (m *ConfigManager) currentRiskControlEnabled() bool {
  394. if snapshot := m.snapshot.Load(); snapshot != nil {
  395. return snapshot.active.RiskControlEnabled
  396. }
  397. return false
  398. }
  399. func (m *ConfigManager) observeExpectedState(raw string, riskControlEnabled bool) {
  400. if m == nil {
  401. return
  402. }
  403. if strings.TrimSpace(raw) == "" {
  404. m.expected.Store(1)
  405. m.expectedBlocking.Store(false)
  406. return
  407. }
  408. var intent struct {
  409. Enabled bool `json:"enabled"`
  410. BlockingEnabled bool `json:"blocking_enabled"`
  411. ConfigVersion int64 `json:"config_version"`
  412. }
  413. if err := json.Unmarshal([]byte(raw), &intent); err != nil {
  414. return
  415. }
  416. if intent.ConfigVersion < 1 {
  417. intent.ConfigVersion = 1
  418. }
  419. m.expected.Store(intent.ConfigVersion)
  420. m.expectedBlocking.Store(riskControlEnabled && intent.Enabled && intent.BlockingEnabled)
  421. }
  422. func (m *ConfigManager) refreshLoop(ctx context.Context) {
  423. defer m.wg.Done()
  424. ticker := time.NewTicker(5 * time.Second)
  425. defer ticker.Stop()
  426. for {
  427. select {
  428. case <-ctx.Done():
  429. return
  430. case <-ticker.C:
  431. if err := m.Reload(ctx); err != nil {
  432. LogWarn(EventConfigReloadDegraded, map[string]any{"status": "degraded", "error_code": "config_ttl_reload_failed"})
  433. }
  434. }
  435. }
  436. }
  437. func (m *ConfigManager) subscribeLoop(ctx context.Context) {
  438. defer m.wg.Done()
  439. pubsub := m.redis.Subscribe(ctx, ConfigInvalidationChannel)
  440. defer func() { _ = pubsub.Close() }()
  441. channel := pubsub.Channel()
  442. for {
  443. select {
  444. case <-ctx.Done():
  445. return
  446. case message, ok := <-channel:
  447. if !ok {
  448. return
  449. }
  450. version, err := strconv.ParseInt(strings.TrimSpace(message.Payload), 10, 64)
  451. if err != nil || version < 1 {
  452. continue
  453. }
  454. m.expected.Store(version)
  455. if err := m.Reload(ctx); err != nil {
  456. // A newer published version failed to activate. Until reload
  457. // succeeds, do not keep serving a potentially stale weaker mode.
  458. if active, ok := m.Active(); !ok || active.ConfigVersion < version {
  459. m.markConfigUntrusted()
  460. }
  461. LogWarn(EventConfigReloadDegraded, map[string]any{
  462. "config_version": version, "status": "degraded", "error_code": "config_invalidation_reload_failed",
  463. })
  464. }
  465. }
  466. }
  467. }
  468. func (m *ConfigManager) recordLoadError(_ error) {
  469. if m == nil {
  470. return
  471. }
  472. now := m.clock.Now()
  473. m.stateMu.Lock()
  474. m.lastLoadError = stableErrorMessage("config_load_failed")
  475. m.lastErrorAt = &now
  476. m.stateMu.Unlock()
  477. }
  478. // clearLoadError drops the recorded load failure and reports whether one was
  479. // pending, so callers can tell a recovery apart from an unchanged reload.
  480. func (m *ConfigManager) clearLoadError() bool {
  481. m.stateMu.Lock()
  482. recovered := m.lastLoadError != ""
  483. m.lastLoadError = ""
  484. m.lastErrorAt = nil
  485. m.stateMu.Unlock()
  486. return recovered
  487. }
  488. func cloneStorageConfig(cfg storageConfig) storageConfig {
  489. cfg.Scanners = append([]string(nil), cfg.Scanners...)
  490. cfg.GroupIDs = append([]int64(nil), cfg.GroupIDs...)
  491. cfg.Endpoints = append([]StorageEndpoint(nil), cfg.Endpoints...)
  492. return cfg
  493. }
  494. func cloneActiveConfig(cfg ActiveConfig) ActiveConfig {
  495. cfg.Scanners = append([]string(nil), cfg.Scanners...)
  496. cfg.GroupIDs = append([]int64(nil), cfg.GroupIDs...)
  497. cfg.Endpoints = append([]ActiveEndpoint(nil), cfg.Endpoints...)
  498. return cfg
  499. }