| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530 |
- package securityaudit
- import (
- "context"
- "database/sql"
- "encoding/json"
- "errors"
- "fmt"
- "strconv"
- "strings"
- "sync"
- "sync/atomic"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/redis/go-redis/v9"
- )
- type activeConfigSnapshot struct {
- storage storageConfig
- active ActiveConfig
- loadedAt time.Time
- }
- type ConfigManager struct {
- db *sql.DB
- settings service.SettingRepository
- redis *redis.Client
- encryptor SecretEncryptor
- clock Clock
- // encryptionKeyConfigured mirrors cfg.Totp.EncryptionKeyConfigured. With an
- // auto-generated (per-boot) key, newly saved endpoint tokens would become
- // undecryptable after the next restart, so Save rejects them (issue #4887).
- encryptionKeyConfigured bool
- snapshot atomic.Pointer[activeConfigSnapshot]
- expected atomic.Int64
- // expectedBlocking records the last storage intent that could be decoded,
- // independently of whether endpoint credentials or the full config could be
- // activated. A config version alone cannot distinguish async from blocking.
- expectedBlocking atomic.Bool
- // configUntrusted is set when a load/reload fails before a trustworthy
- // snapshot is installed. Combined with expectedBlocking, EffectiveMode
- // fails closed so a persisted blocking policy cannot be silently skipped
- // after startup or invalidation errors. Without blocking intent, untrusted
- // alone must not force ModeBlocking—Prompt Audit is default-off and must
- // not take the gateway down for every API request (see issue #4560).
- configUntrusted atomic.Bool
- stateMu sync.RWMutex
- lastLoadError string
- lastErrorAt *time.Time
- lifecycleMu sync.Mutex
- cancel context.CancelFunc
- wg sync.WaitGroup
- }
- func NewConfigManager(db *sql.DB, settings service.SettingRepository, redisClient *redis.Client, encryptor service.SecretEncryptor, cfg *config.Config) *ConfigManager {
- return &ConfigManager{
- db: db, settings: settings, redis: redisClient, encryptor: encryptor, clock: realClock{},
- encryptionKeyConfigured: cfg != nil && cfg.Totp.EncryptionKeyConfigured,
- }
- }
- func (m *ConfigManager) Start(ctx context.Context) error {
- if m == nil {
- return errors.New("prompt audit config manager unavailable")
- }
- m.lifecycleMu.Lock()
- if m.cancel != nil {
- m.lifecycleMu.Unlock()
- return nil
- }
- runCtx, cancel := context.WithCancel(ctx)
- m.cancel = cancel
- m.lifecycleMu.Unlock()
- loadErr := m.Reload(runCtx)
- if loadErr != nil {
- m.markConfigUntrusted()
- }
- m.wg.Add(1)
- go m.refreshLoop(runCtx)
- if m.redis != nil {
- m.wg.Add(1)
- go m.subscribeLoop(runCtx)
- }
- return loadErr
- }
- func (m *ConfigManager) Shutdown(_ context.Context) error {
- if m == nil {
- return nil
- }
- m.lifecycleMu.Lock()
- cancel := m.cancel
- m.cancel = nil
- m.lifecycleMu.Unlock()
- if cancel != nil {
- cancel()
- }
- m.wg.Wait()
- return nil
- }
- func (m *ConfigManager) Reload(ctx context.Context) error {
- if m == nil || m.settings == nil {
- m.markUntrustedIfNoActiveSnapshot()
- return errors.New("prompt audit setting repository unavailable")
- }
- values, err := m.settings.GetMultiple(ctx, []string{SettingKeyPromptAuditConfig, SettingKeyRiskControl})
- if err != nil {
- m.recordLoadError(err)
- m.markUntrustedIfNoActiveSnapshot()
- return err
- }
- m.observeExpectedState(values[SettingKeyPromptAuditConfig], values[SettingKeyRiskControl] == "true")
- storage, err := ParseStorageConfig(values[SettingKeyPromptAuditConfig])
- if err != nil {
- m.recordLoadError(err)
- m.markUntrustedIfNoActiveSnapshot()
- return err
- }
- m.expected.Store(storage.ConfigVersion)
- m.expectedBlocking.Store(values[SettingKeyRiskControl] == "true" && storage.Enabled && storage.BlockingEnabled)
- active, err := ActiveFromStorage(storage, values[SettingKeyRiskControl] == "true", m.encryptor)
- if err != nil {
- m.recordLoadError(err)
- // expectedBlocking may already require fail-closed via BlockingActivationDegraded.
- m.markUntrustedIfNoActiveSnapshot()
- return err
- }
- now := m.clock.Now()
- previous := m.snapshot.Load()
- m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(storage), active: cloneActiveConfig(active), loadedAt: now})
- m.configUntrusted.Store(false)
- recovered := m.clearLoadError()
- m.logInvalidTokenEndpoints(previous, active)
- // refreshLoop calls Reload every 5s, so logging every successful load turns
- // config_loaded into a heartbeat that buries real config changes.
- if recovered || shouldLogConfigLoaded(previous, storage, active) {
- LogInfo(EventConfigLoaded, map[string]any{
- "config_version": storage.ConfigVersion, "status": "loaded",
- })
- }
- return nil
- }
- // shouldLogConfigLoaded reports whether a successful reload carries news: the
- // first snapshot, a new config version (every admin save bumps it under the
- // advisory lock in UpdateConfig) or a flip of the global risk control gate,
- // which lives in its own setting and so leaves the version untouched.
- func shouldLogConfigLoaded(previous *activeConfigSnapshot, storage storageConfig, active ActiveConfig) bool {
- return previous == nil ||
- previous.storage.ConfigVersion != storage.ConfigVersion ||
- previous.active.RiskControlEnabled != active.RiskControlEnabled
- }
- // logInvalidTokenEndpoints warns once per change (not on every 5s refresh)
- // when stored endpoint tokens cannot be decrypted with the current key.
- func (m *ConfigManager) logInvalidTokenEndpoints(previous *activeConfigSnapshot, active ActiveConfig) {
- invalid := active.InvalidTokenEndpointIDs()
- if len(invalid) == 0 {
- return
- }
- if previous != nil {
- prior := previous.active.InvalidTokenEndpointIDs()
- if len(prior) == len(invalid) {
- same := true
- for i := range invalid {
- if prior[i] != invalid[i] {
- same = false
- break
- }
- }
- if same && previous.active.ConfigVersion == active.ConfigVersion {
- return
- }
- }
- }
- LogWarn(EventConfigTokenInvalid, map[string]any{
- "config_version": active.ConfigVersion, "status": "degraded",
- "error_code": "endpoint_token_undecryptable", "guard_endpoint_id": strings.Join(invalid, ","),
- })
- }
- func (m *ConfigManager) Active() (ActiveConfig, bool) {
- if m == nil {
- return ActiveConfig{}, false
- }
- snapshot := m.snapshot.Load()
- if snapshot == nil {
- return ActiveConfig{}, false
- }
- return cloneActiveConfig(snapshot.active), true
- }
- func (m *ConfigManager) BlockingActivationDegraded() bool {
- if m == nil {
- return false
- }
- // Fail closed only when storage intent requires blocking. Untrusted config
- // without blocking intent must remain ModeOff so administrators can still
- // operate the gateway and turn Prompt Audit off after a failed reload.
- if !m.expectedBlocking.Load() {
- return false
- }
- if m.configUntrusted.Load() {
- return true
- }
- active, ok := m.Active()
- if !ok {
- return true
- }
- // A still-active weaker snapshot after a failed blocking activation must not
- // keep serving allow decisions under the old off/async mode.
- return active.EffectiveMode() != ModeBlocking
- }
- func (m *ConfigManager) EffectiveMode() Mode {
- if m != nil && m.BlockingActivationDegraded() {
- return ModeBlocking
- }
- active, ok := m.Active()
- if !ok {
- return ModeOff
- }
- return active.EffectiveMode()
- }
- func (m *ConfigManager) markConfigUntrusted() {
- if m == nil {
- return
- }
- m.configUntrusted.Store(true)
- }
- func (m *ConfigManager) markUntrustedIfNoActiveSnapshot() {
- if m == nil {
- return
- }
- if _, ok := m.Active(); !ok {
- m.markConfigUntrusted()
- }
- }
- func (m *ConfigManager) Public() (PublicConfig, error) {
- if m == nil {
- return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用")
- }
- snapshot := m.snapshot.Load()
- if snapshot == nil {
- return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用")
- }
- return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled, snapshot.active.InvalidTokenEndpointIDs()), nil
- }
- func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) {
- if m == nil || m.db == nil || m.encryptor == nil {
- return PublicConfig{}, errors.New("prompt audit config persistence unavailable")
- }
- if req.ExpectedConfigVersion < 1 {
- return PublicConfig{}, infraerrors.BadRequest("prompt_audit_expected_config_version_required", "必须提供有效的配置版本")
- }
- tx, err := m.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
- if err != nil {
- return PublicConfig{}, err
- }
- defer func() { _ = tx.Rollback() }()
- if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, promptAuditConfigLockKey); err != nil {
- return PublicConfig{}, err
- }
- current := DefaultStorageConfig()
- var raw string
- err = tx.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=$1 FOR UPDATE`, SettingKeyPromptAuditConfig).Scan(&raw)
- if err != nil && !errors.Is(err, sql.ErrNoRows) {
- return PublicConfig{}, err
- }
- if err == nil {
- current, err = ParseStorageConfig(raw)
- if err != nil {
- return PublicConfig{}, err
- }
- }
- if current.ConfigVersion != req.ExpectedConfigVersion {
- return PublicConfig{}, infraerrors.Conflict(ErrorCodeConfigConflict, "提示词审计配置已被其他管理员更新")
- }
- next, err := m.buildNextStorage(current, req, actorID)
- if err != nil {
- return PublicConfig{}, err
- }
- next.ConfigVersion = current.ConfigVersion + 1
- next.UpdatedAt = m.clock.Now()
- next.UpdatedBy = actorID
- next.ChangeSummary = changeSummary(next)
- rawNext, err := json.Marshal(next)
- if err != nil {
- return PublicConfig{}, err
- }
- if _, err := tx.ExecContext(ctx, `
- INSERT INTO settings (key,value,updated_at) VALUES ($1,$2,NOW())
- ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=EXCLUDED.updated_at`,
- SettingKeyPromptAuditConfig, string(rawNext)); err != nil {
- return PublicConfig{}, err
- }
- if err := tx.Commit(); err != nil {
- return PublicConfig{}, err
- }
- // Install the snapshot with the current global gate, not merely the value
- // cached when this process last reloaded Prompt Audit configuration.
- riskControlEnabled := m.currentRiskControlEnabled()
- if values, getErr := m.settings.GetMultiple(ctx, []string{SettingKeyRiskControl}); getErr == nil {
- riskControlEnabled = values[SettingKeyRiskControl] == "true"
- }
- active, err := ActiveFromStorage(next, riskControlEnabled, m.encryptor)
- if err != nil {
- return PublicConfig{}, err
- }
- m.expected.Store(next.ConfigVersion)
- m.expectedBlocking.Store(active.RiskControlEnabled && next.Enabled && next.BlockingEnabled)
- previous := m.snapshot.Load()
- m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(next), active: cloneActiveConfig(active), loadedAt: m.clock.Now()})
- // A successful admin save installs a trustworthy snapshot; clear any prior
- // fail-closed degradation so disabling audit actually takes effect.
- m.configUntrusted.Store(false)
- m.clearLoadError()
- m.logInvalidTokenEndpoints(previous, active)
- LogInfo(EventConfigUpdated, map[string]any{
- "config_version": next.ConfigVersion, "status": "updated",
- })
- if m.redis != nil {
- if err := m.redis.Publish(ctx, ConfigInvalidationChannel, strconv.FormatInt(next.ConfigVersion, 10)).Err(); err != nil {
- LogWarn(EventConfigReloadDegraded, map[string]any{
- "config_version": next.ConfigVersion, "status": "degraded", "error_code": "config_invalidation_publish_failed",
- })
- }
- }
- return PublicFromStorage(next, active.RiskControlEnabled, active.InvalidTokenEndpointIDs()), nil
- }
- func (m *ConfigManager) buildNextStorage(current storageConfig, req UpdateConfigRequest, actorID int64) (storageConfig, error) {
- if err := validateUpdateConfigRequest(req); err != nil {
- return storageConfig{}, err
- }
- currentByID := make(map[string]StorageEndpoint, len(current.Endpoints))
- for _, endpoint := range current.Endpoints {
- currentByID[endpoint.ID] = endpoint
- }
- next := storageConfig{
- Enabled: req.Enabled, BlockingEnabled: req.BlockingEnabled, BlockingLatestTurnOnly: req.BlockingLatestTurnOnly, StorePassEvents: req.StorePassEvents,
- Strategy: strings.TrimSpace(req.Strategy), WorkerCount: req.WorkerCount,
- QueueCapacity: req.QueueCapacity, Scanners: append([]string(nil), req.Scanners...),
- AllGroups: req.AllGroups, GroupIDs: append([]int64(nil), req.GroupIDs...),
- ConfigVersion: current.ConfigVersion, UpdatedBy: actorID,
- Endpoints: make([]StorageEndpoint, 0, len(req.Endpoints)),
- }
- for _, endpoint := range req.Endpoints {
- baseURL, err := NormalizeBaseURL(endpoint.BaseURL)
- if err != nil {
- return storageConfig{}, err
- }
- stored := StorageEndpoint{
- ID: strings.TrimSpace(endpoint.ID), Name: strings.TrimSpace(endpoint.Name),
- Protocol: strings.TrimSpace(endpoint.Protocol), BaseURL: baseURL, Model: strings.TrimSpace(endpoint.Model),
- TimeoutMS: endpoint.TimeoutMS, InputLimit: endpoint.InputLimit, Enabled: endpoint.Enabled,
- }
- old, hadOld := currentByID[stored.ID]
- switch {
- case endpoint.ClearToken:
- stored.TokenCiphertext = ""
- case strings.TrimSpace(endpoint.Token) != "":
- if !m.encryptionKeyConfigured {
- return storageConfig{}, infraerrors.BadRequest(ErrorCodeEncryptionKeyRequired,
- "未配置固定加密密钥,审计节点 Token 将在服务重启后失效。请先设置 TOTP_ENCRYPTION_KEY 环境变量(64 位十六进制)并重启服务")
- }
- ciphertext, err := m.encryptor.Encrypt(strings.TrimSpace(endpoint.Token))
- if err != nil {
- return storageConfig{}, fmt.Errorf("encrypt prompt audit endpoint token: %w", err)
- }
- stored.TokenCiphertext = ciphertext
- case hadOld:
- stored.TokenCiphertext = old.TokenCiphertext
- }
- next.Endpoints = append(next.Endpoints, stored)
- }
- normalizeStorageConfig(&next)
- if err := validateStorageConfig(next); err != nil {
- return storageConfig{}, err
- }
- return next, nil
- }
- func (m *ConfigManager) RuntimeState() (expected int64, active int64, loadedAt *time.Time, loadError string) {
- if m == nil {
- return 1, 0, nil, "config_manager_unavailable"
- }
- expected = m.expected.Load()
- if expected < 1 {
- expected = 1
- }
- if snapshot := m.snapshot.Load(); snapshot != nil {
- active = snapshot.active.ConfigVersion
- value := snapshot.loadedAt
- loadedAt = &value
- }
- m.stateMu.RLock()
- loadError = m.lastLoadError
- m.stateMu.RUnlock()
- return
- }
- func (m *ConfigManager) Encrypt(value string) (string, error) { return m.encryptor.Encrypt(value) }
- func (m *ConfigManager) Decrypt(value string) (string, error) { return m.encryptor.Decrypt(value) }
- func (m *ConfigManager) currentRiskControlEnabled() bool {
- if snapshot := m.snapshot.Load(); snapshot != nil {
- return snapshot.active.RiskControlEnabled
- }
- return false
- }
- func (m *ConfigManager) observeExpectedState(raw string, riskControlEnabled bool) {
- if m == nil {
- return
- }
- if strings.TrimSpace(raw) == "" {
- m.expected.Store(1)
- m.expectedBlocking.Store(false)
- return
- }
- var intent struct {
- Enabled bool `json:"enabled"`
- BlockingEnabled bool `json:"blocking_enabled"`
- ConfigVersion int64 `json:"config_version"`
- }
- if err := json.Unmarshal([]byte(raw), &intent); err != nil {
- return
- }
- if intent.ConfigVersion < 1 {
- intent.ConfigVersion = 1
- }
- m.expected.Store(intent.ConfigVersion)
- m.expectedBlocking.Store(riskControlEnabled && intent.Enabled && intent.BlockingEnabled)
- }
- func (m *ConfigManager) refreshLoop(ctx context.Context) {
- defer m.wg.Done()
- ticker := time.NewTicker(5 * time.Second)
- defer ticker.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- if err := m.Reload(ctx); err != nil {
- LogWarn(EventConfigReloadDegraded, map[string]any{"status": "degraded", "error_code": "config_ttl_reload_failed"})
- }
- }
- }
- }
- func (m *ConfigManager) subscribeLoop(ctx context.Context) {
- defer m.wg.Done()
- pubsub := m.redis.Subscribe(ctx, ConfigInvalidationChannel)
- defer func() { _ = pubsub.Close() }()
- channel := pubsub.Channel()
- for {
- select {
- case <-ctx.Done():
- return
- case message, ok := <-channel:
- if !ok {
- return
- }
- version, err := strconv.ParseInt(strings.TrimSpace(message.Payload), 10, 64)
- if err != nil || version < 1 {
- continue
- }
- m.expected.Store(version)
- if err := m.Reload(ctx); err != nil {
- // A newer published version failed to activate. Until reload
- // succeeds, do not keep serving a potentially stale weaker mode.
- if active, ok := m.Active(); !ok || active.ConfigVersion < version {
- m.markConfigUntrusted()
- }
- LogWarn(EventConfigReloadDegraded, map[string]any{
- "config_version": version, "status": "degraded", "error_code": "config_invalidation_reload_failed",
- })
- }
- }
- }
- }
- func (m *ConfigManager) recordLoadError(_ error) {
- if m == nil {
- return
- }
- now := m.clock.Now()
- m.stateMu.Lock()
- m.lastLoadError = stableErrorMessage("config_load_failed")
- m.lastErrorAt = &now
- m.stateMu.Unlock()
- }
- // clearLoadError drops the recorded load failure and reports whether one was
- // pending, so callers can tell a recovery apart from an unchanged reload.
- func (m *ConfigManager) clearLoadError() bool {
- m.stateMu.Lock()
- recovered := m.lastLoadError != ""
- m.lastLoadError = ""
- m.lastErrorAt = nil
- m.stateMu.Unlock()
- return recovered
- }
- func cloneStorageConfig(cfg storageConfig) storageConfig {
- cfg.Scanners = append([]string(nil), cfg.Scanners...)
- cfg.GroupIDs = append([]int64(nil), cfg.GroupIDs...)
- cfg.Endpoints = append([]StorageEndpoint(nil), cfg.Endpoints...)
- return cfg
- }
- func cloneActiveConfig(cfg ActiveConfig) ActiveConfig {
- cfg.Scanners = append([]string(nil), cfg.Scanners...)
- cfg.GroupIDs = append([]int64(nil), cfg.GroupIDs...)
- cfg.Endpoints = append([]ActiveEndpoint(nil), cfg.Endpoints...)
- return cfg
- }
|