1204 lines
37 KiB
Go
1204 lines
37 KiB
Go
package config
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Smail/utils/credential"
|
|
)
|
|
|
|
type ServiceName string
|
|
|
|
const (
|
|
ServiceDatabase ServiceName = "database"
|
|
ServiceHTTP ServiceName = "http"
|
|
ServiceSMTP ServiceName = "smtp"
|
|
ServiceEmail ServiceName = "email"
|
|
ServiceSSL ServiceName = "ssl"
|
|
ServicePlugins ServiceName = "plugins"
|
|
)
|
|
|
|
type PublicIssue struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type ConfigStatus struct {
|
|
SetupState SetupState `json:"setupState"`
|
|
TLSMode string `json:"tlsMode"`
|
|
ActiveGeneration uint64 `json:"activeGeneration"`
|
|
LastValidAt *time.Time `json:"lastValidAt,omitempty"`
|
|
PendingRestart []ServiceName `json:"pendingRestart,omitempty"`
|
|
DegradedReasons []PublicIssue `json:"degradedReasons,omitempty"`
|
|
}
|
|
|
|
type ValidateRequest struct {
|
|
Section Section
|
|
ExpectedGeneration uint64
|
|
Payload json.RawMessage
|
|
SecretChanges map[string]SecretChange
|
|
}
|
|
|
|
type UpdateRequest struct {
|
|
Section Section
|
|
ExpectedGeneration uint64
|
|
Payload json.RawMessage
|
|
SecretChanges map[string]SecretChange
|
|
Actor AuditActor
|
|
Confirmation StepUpConfirmation
|
|
}
|
|
|
|
type AuditActor struct {
|
|
UserID string
|
|
Name string
|
|
Role string
|
|
SessionID string
|
|
RecentAuth bool
|
|
RecoveryCodeConfigured bool
|
|
ExternalEmailConfigured bool
|
|
}
|
|
|
|
type StepUpConfirmation struct {
|
|
Confirmed bool
|
|
Method string
|
|
}
|
|
|
|
type ValidationResult struct {
|
|
Valid bool `json:"valid"`
|
|
Generation uint64 `json:"generation"`
|
|
Issues []PublicIssue `json:"issues,omitempty"`
|
|
}
|
|
|
|
type UpdateResult struct {
|
|
CandidateID string
|
|
Generation uint64
|
|
Candidate RuntimeConfig
|
|
Pending bool
|
|
}
|
|
|
|
type ApplyResult struct {
|
|
Snapshot RuntimeConfig
|
|
Generation uint64
|
|
AppliedAt time.Time
|
|
}
|
|
|
|
type RollbackPreview struct {
|
|
CurrentGeneration uint64 `json:"currentGeneration"`
|
|
TargetGeneration uint64 `json:"targetGeneration"`
|
|
CandidateGeneration uint64 `json:"candidateGeneration"`
|
|
ChangedSections []Section `json:"changedSections"`
|
|
}
|
|
|
|
type rollbackTicket struct {
|
|
ActorID string
|
|
SessionID string
|
|
TargetGeneration uint64
|
|
ExpiresAt time.Time
|
|
InFlight bool
|
|
}
|
|
|
|
type LegacyPluginSecret struct {
|
|
PluginID string
|
|
FieldName string
|
|
Plaintext string
|
|
}
|
|
|
|
type LegacyPluginSecretImporter func(context.Context, []LegacyPluginSecret) error
|
|
|
|
var ErrPluginSecretMigrationRequired = errors.New("plugin secret migration is required")
|
|
|
|
type runtimeSnapshotStore interface {
|
|
Validate(RuntimeConfig) error
|
|
Publish(RuntimeConfig) error
|
|
Snapshot() (RuntimeConfig, error)
|
|
}
|
|
|
|
type ConfigManager struct {
|
|
mu sync.RWMutex
|
|
writeMu sync.Mutex
|
|
loader *ConfigLoader
|
|
snapshots runtimeSnapshotStore
|
|
current RuntimeConfig
|
|
pending *pendingConfig
|
|
status ConfigStatus
|
|
transactions *TransactionStore
|
|
vault *credential.Vault
|
|
options RuntimeValidationOptions
|
|
security ConfigSecurity
|
|
ticketMu sync.Mutex
|
|
rollbackTickets map[string]rollbackTicket
|
|
}
|
|
|
|
func NewConfigManager(root string, options ConfigLoaderOptions) (*ConfigManager, error) {
|
|
return NewConfigManagerWithSecurity(root, options, ConfigSecurity{})
|
|
}
|
|
|
|
func NewConfigManagerWithSecurity(root string, options ConfigLoaderOptions, security ConfigSecurity) (*ConfigManager, error) {
|
|
if security.Authorize == nil {
|
|
security.Authorize = DefaultConfigAuthorization
|
|
}
|
|
if security.Confirm == nil {
|
|
security.Confirm = DefaultConfigConfirmation
|
|
}
|
|
if security.Audit == nil {
|
|
security.Audit = RuntimeConfigLogWriter{}
|
|
}
|
|
return newConfigManager(root, options, security)
|
|
}
|
|
|
|
func newConfigManager(root string, options ConfigLoaderOptions, security ConfigSecurity) (*ConfigManager, error) {
|
|
transactions := NewTransactionStore(root, options.Validation)
|
|
if err := transactions.Recover(); err != nil {
|
|
return nil, err
|
|
}
|
|
vault, err := credential.NewVault(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
loader := NewConfigLoader(root, options)
|
|
runtime, err := loader.Load()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ValidateRuntimeSecrets(vault, runtime); err != nil {
|
|
return nil, err
|
|
}
|
|
snapshots, err := NewSnapshotStore(runtime, options.Validation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := runtime.LoadedAt
|
|
return &ConfigManager{
|
|
loader: loader,
|
|
snapshots: snapshots,
|
|
transactions: transactions,
|
|
current: runtime,
|
|
vault: vault,
|
|
options: options.Validation,
|
|
security: security,
|
|
rollbackTickets: make(map[string]rollbackTicket),
|
|
status: ConfigStatus{SetupState: runtime.SetupState, ActiveGeneration: runtime.Generation, LastValidAt: &now},
|
|
}, nil
|
|
}
|
|
|
|
func (m *ConfigManager) Load(ctx context.Context) error {
|
|
if err := contextError(ctx); err != nil {
|
|
return err
|
|
}
|
|
if m == nil || m.loader == nil {
|
|
return ErrManagerStorage
|
|
}
|
|
m.writeMu.Lock()
|
|
defer m.writeMu.Unlock()
|
|
runtime, err := m.loader.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err := ValidateRuntimeSecrets(m.vault, runtime); err != nil {
|
|
return err
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err := m.publishSnapshot(runtime); err != nil {
|
|
return err
|
|
}
|
|
now := runtime.LoadedAt
|
|
m.mu.Lock()
|
|
previousPending := m.pending
|
|
m.current = runtime
|
|
m.pending = nil
|
|
m.status = ConfigStatus{SetupState: runtime.SetupState, ActiveGeneration: runtime.Generation, LastValidAt: &now}
|
|
m.mu.Unlock()
|
|
if previousPending != nil {
|
|
releaseManagedSSL(m.loader.root, previousPending.Candidate.SSL.TLS)
|
|
}
|
|
maintainManagedSSL(m.loader.root, runtime.SSL.TLS)
|
|
return nil
|
|
}
|
|
|
|
func (m *ConfigManager) Snapshot() RuntimeConfig {
|
|
if m == nil || m.snapshots == nil {
|
|
return RuntimeConfig{}
|
|
}
|
|
snapshot, err := m.snapshots.Snapshot()
|
|
if err != nil {
|
|
return RuntimeConfig{}
|
|
}
|
|
return snapshot
|
|
}
|
|
|
|
func (m *ConfigManager) Status() ConfigStatus {
|
|
if m == nil {
|
|
return ConfigStatus{}
|
|
}
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
status := m.status
|
|
status.TLSMode = m.current.Root.HTTP.TLSMode
|
|
status.PendingRestart = append([]ServiceName(nil), status.PendingRestart...)
|
|
status.DegradedReasons = append([]PublicIssue(nil), status.DegradedReasons...)
|
|
if status.LastValidAt != nil {
|
|
value := *status.LastValidAt
|
|
status.LastValidAt = &value
|
|
}
|
|
return status
|
|
}
|
|
|
|
func (m *ConfigManager) Validate(ctx context.Context, req ValidateRequest) (*ValidationResult, error) {
|
|
return m.validate(ctx, req, nil)
|
|
}
|
|
|
|
// ValidateAuthorized 对外部请求使用与保存一致的授权;内部校验不暴露为 HTTP 入口。
|
|
func (m *ConfigManager) ValidateAuthorized(ctx context.Context, req ValidateRequest, actor AuditActor) (*ValidationResult, error) {
|
|
if m == nil {
|
|
return nil, ErrManagerStorage
|
|
}
|
|
if err := m.authorize(ctx, actor, ConfigOperationUpdate, req.Section); err != nil {
|
|
return nil, err
|
|
}
|
|
return m.validate(ctx, req, &actor)
|
|
}
|
|
|
|
func (m *ConfigManager) validate(ctx context.Context, req ValidateRequest, actor *AuditActor) (*ValidationResult, error) {
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if m == nil {
|
|
return nil, ErrManagerStorage
|
|
}
|
|
m.mu.RLock()
|
|
pending := m.pending != nil
|
|
currentGeneration := m.current.Generation
|
|
if req.ExpectedGeneration != 0 && (req.ExpectedGeneration != currentGeneration || pending) {
|
|
m.mu.RUnlock()
|
|
return nil, ErrGenerationConflict
|
|
}
|
|
current := cloneRuntimeConfig(m.current)
|
|
m.mu.RUnlock()
|
|
candidate, err := applySectionPayload(current, req.Section, req.Payload, req.SecretChanges, m.vault)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if actor != nil && req.Section == SectionPlugins {
|
|
if err := AuthorizePluginChanges(*actor, current, candidate); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if req.Section == SectionSSL {
|
|
if _, err := prepareCustomSSLPaths(m.loader.root, &candidate); err != nil {
|
|
issue, ok := SSLValidationIssue(err)
|
|
if !ok {
|
|
issue = PublicIssue{Code: "SSL_INVALID", Message: "证书校验失败,请检查证书文件"}
|
|
}
|
|
return &ValidationResult{Valid: false, Generation: current.Generation, Issues: []PublicIssue{issue}}, nil
|
|
}
|
|
retainPreviousSSL(m.loader.root, &candidate.SSL.TLS, current.SSL.TLS)
|
|
}
|
|
setCandidateGeneration(&candidate, current.Generation+1)
|
|
if err := ValidateRuntimeConfig(candidate, m.options); err != nil {
|
|
return &ValidationResult{Valid: false, Generation: current.Generation, Issues: []PublicIssue{{Code: "CONFIG_INVALID", Message: err.Error()}}}, nil
|
|
}
|
|
return &ValidationResult{Valid: true, Generation: candidate.Generation}, nil
|
|
}
|
|
|
|
func (m *ConfigManager) Update(ctx context.Context, req UpdateRequest) (*UpdateResult, error) {
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if m == nil {
|
|
return nil, ErrManagerStorage
|
|
}
|
|
m.writeMu.Lock()
|
|
defer m.writeMu.Unlock()
|
|
m.mu.RLock()
|
|
current := cloneRuntimeConfig(m.current)
|
|
pending := m.pending != nil
|
|
m.mu.RUnlock()
|
|
if req.ExpectedGeneration != current.Generation || pending {
|
|
return nil, ErrGenerationConflict
|
|
}
|
|
if err := m.authorize(ctx, req.Actor, ConfigOperationUpdate, req.Section); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
candidate, err := applySectionPayload(current, req.Section, req.Payload, req.SecretChanges, m.vault)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var sslMaterial *customSSLMaterial
|
|
if req.Section == SectionPlugins {
|
|
if err := AuthorizePluginChanges(req.Actor, current, candidate); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if req.Section == SectionSSL {
|
|
sslMaterial, err = prepareCustomSSLPaths(m.loader.root, &candidate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
retainPreviousSSL(m.loader.root, &candidate.SSL.TLS, current.SSL.TLS)
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
setCandidateGeneration(&candidate, current.Generation+1)
|
|
if err := ValidateRuntimeConfig(candidate, m.options); err != nil {
|
|
return nil, err
|
|
}
|
|
if requiresSecretStepUp(req.SecretChanges) && !validStepUpConfirmation(req.Confirmation) {
|
|
return nil, ErrStepUpRequired
|
|
}
|
|
if err := m.confirm(ctx, req.Actor, req.Confirmation, ConfigOperationUpdate, req.Section); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
id, err := newRollbackTicketID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sslMaterial.stage(m.loader.root, &candidate); err != nil {
|
|
return nil, err
|
|
}
|
|
setCandidateGeneration(&candidate, current.Generation+1)
|
|
m.mu.Lock()
|
|
m.pending = &pendingConfig{ID: id, Candidate: candidate, Section: req.Section, Actor: req.Actor, Confirmation: req.Confirmation, PreviousRestart: append([]ServiceName(nil), m.status.PendingRestart...)}
|
|
m.status.PendingRestart = mergeServices(m.status.PendingRestart, servicesForChange(req.Section, current, candidate))
|
|
m.mu.Unlock()
|
|
return &UpdateResult{CandidateID: id, Generation: candidate.Generation, Candidate: cloneRuntimeConfig(candidate), Pending: true}, nil
|
|
}
|
|
|
|
func (m *ConfigManager) Apply(ctx context.Context, generation uint64) (*ApplyResult, error) {
|
|
return m.apply(ctx, generation, AuditActor{})
|
|
}
|
|
|
|
// ApplyAuthorized 仅允许发起候选配置的同一操作者应用配置。
|
|
func (m *ConfigManager) ApplyAuthorized(ctx context.Context, generation uint64, actor AuditActor) (*ApplyResult, error) {
|
|
if actor.UserID == "" || actor.SessionID == "" {
|
|
return nil, ErrAuthorizationRequired
|
|
}
|
|
return m.apply(ctx, generation, actor)
|
|
}
|
|
|
|
// ApplyPendingAuthorized 同时校验候选标识和分区,避免取消后代次复用导致旧请求误应用。
|
|
func (m *ConfigManager) ApplyPendingAuthorized(ctx context.Context, generation uint64, id string, section Section, actor AuditActor) (*ApplyResult, error) {
|
|
if id == "" || actor.UserID == "" || actor.SessionID == "" {
|
|
return nil, ErrAuthorizationRequired
|
|
}
|
|
return m.apply(ctx, generation, actor, id, string(section))
|
|
}
|
|
|
|
func (m *ConfigManager) apply(ctx context.Context, generation uint64, actor AuditActor, identity ...string) (*ApplyResult, error) {
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if m == nil {
|
|
return nil, ErrManagerStorage
|
|
}
|
|
m.writeMu.Lock()
|
|
defer m.writeMu.Unlock()
|
|
m.mu.RLock()
|
|
current := cloneRuntimeConfig(m.current)
|
|
var pending pendingConfig
|
|
if m.pending != nil {
|
|
pending = *m.pending
|
|
pending.Candidate = cloneRuntimeConfig(pending.Candidate)
|
|
}
|
|
m.mu.RUnlock()
|
|
candidate := pending.Candidate
|
|
if len(identity) > 0 && (len(identity) != 2 || identity[0] != pending.ID || identity[1] != string(pending.Section)) {
|
|
return nil, ErrGenerationConflict
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if actor.UserID != "" && (pending.Actor.UserID != actor.UserID || pending.Actor.SessionID != actor.SessionID) {
|
|
return nil, ErrAuthorizationRequired
|
|
}
|
|
if actor.UserID != "" {
|
|
if err := m.authorize(ctx, actor, ConfigOperationUpdate, pending.Section); err != nil {
|
|
return nil, err
|
|
}
|
|
// 暂存后角色可能变化,应用时必须重新检查程序与权限边界。
|
|
if pending.Section == SectionPlugins {
|
|
if err := AuthorizePluginChanges(actor, current, candidate); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if generation != candidate.Generation || candidate.Generation != current.Generation+1 {
|
|
return nil, ErrGenerationConflict
|
|
}
|
|
if err := ValidateRuntimeConfig(candidate, m.options); err != nil {
|
|
return nil, err
|
|
}
|
|
if pending.Section == SectionSSL {
|
|
if err := validateCustomSSLReferences(m.loader.root, candidate); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := ValidateRuntimeSecrets(m.vault, current); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ValidateRuntimeSecrets(m.vault, candidate); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := m.snapshots.Validate(candidate); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if !pending.Committed {
|
|
if _, _, err := m.transactions.CreateBackupIfDue(current, time.Now().UTC()); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := m.transactions.Commit(current, candidate); err != nil {
|
|
return nil, err
|
|
}
|
|
m.mu.Lock()
|
|
m.pending.Committed = true
|
|
m.mu.Unlock()
|
|
}
|
|
if err := m.publishSnapshot(candidate); err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now().UTC()
|
|
m.mu.Lock()
|
|
m.current = cloneRuntimeConfig(candidate)
|
|
m.pending = nil
|
|
pendingRestart := append([]ServiceName(nil), m.status.PendingRestart...)
|
|
m.status = ConfigStatus{SetupState: candidate.SetupState, ActiveGeneration: candidate.Generation, LastValidAt: &now, PendingRestart: pendingRestart}
|
|
m.mu.Unlock()
|
|
maintainManagedSSL(m.loader.root, candidate.SSL.TLS, current.SSL.TLS)
|
|
if err := m.writeAudit(ctx, ConfigAuditEvent{
|
|
Operation: ConfigOperationUpdate,
|
|
Section: pending.Section,
|
|
ActorID: pending.Actor.UserID,
|
|
ActorRole: pending.Actor.Role,
|
|
ActorName: pending.Actor.Name,
|
|
Changes: AuditChanges(current, candidate),
|
|
FromGeneration: current.Generation,
|
|
ToGeneration: candidate.Generation,
|
|
ChangedFields: changedFields(current, candidate, pending.Section),
|
|
ConfirmationMethod: pending.Confirmation.Method,
|
|
Result: "success",
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return &ApplyResult{Snapshot: cloneRuntimeConfig(candidate), Generation: candidate.Generation, AppliedAt: now}, nil
|
|
}
|
|
|
|
func (m *ConfigManager) Rollback(ctx context.Context, generation uint64) (*ApplyResult, error) {
|
|
return m.rollback(ctx, generation, AuditActor{}, StepUpConfirmation{})
|
|
}
|
|
|
|
func (m *ConfigManager) RollbackAuthorized(ctx context.Context, generation uint64, actor AuditActor, confirmation StepUpConfirmation) (*ApplyResult, error) {
|
|
ticket, err := m.IssueRollbackTicket(ctx, generation, actor, confirmation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return m.RollbackWithTicket(ctx, ticket, actor)
|
|
}
|
|
|
|
func (m *ConfigManager) PreviewRollback(ctx context.Context, generation uint64, actor AuditActor) (*RollbackPreview, error) {
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if m == nil || generation == 0 {
|
|
return nil, ErrManagerStorage
|
|
}
|
|
if err := m.authorize(ctx, actor, ConfigOperationRollback, SectionRoot); err != nil {
|
|
return nil, err
|
|
}
|
|
m.mu.RLock()
|
|
current := cloneRuntimeConfig(m.current)
|
|
currentGeneration := current.Generation
|
|
pending := m.pending != nil
|
|
m.mu.RUnlock()
|
|
if pending || generation >= currentGeneration {
|
|
return nil, ErrGenerationConflict
|
|
}
|
|
backup, err := m.transactions.LoadBackup(generation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ValidateRuntimeSecrets(m.vault, backup); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateRollbackSSL(m.loader.root, backup); err != nil {
|
|
return nil, err
|
|
}
|
|
changed := make(map[Section]bool)
|
|
for _, change := range AuditChanges(current, backup) {
|
|
section, _, _ := strings.Cut(change.Field, ".")
|
|
changed[Section(section)] = true
|
|
}
|
|
changedSections := make([]Section, 0, len(changed))
|
|
for _, section := range []Section{SectionRoot, SectionSMTP, SectionEmail, SectionSSL, SectionPlugins} {
|
|
if changed[section] {
|
|
changedSections = append(changedSections, section)
|
|
}
|
|
}
|
|
return &RollbackPreview{
|
|
CurrentGeneration: currentGeneration,
|
|
TargetGeneration: generation,
|
|
CandidateGeneration: currentGeneration + 1,
|
|
ChangedSections: changedSections,
|
|
}, nil
|
|
}
|
|
|
|
func (m *ConfigManager) IssueRollbackTicket(ctx context.Context, generation uint64, actor AuditActor, confirmation StepUpConfirmation) (string, error) {
|
|
if err := contextError(ctx); err != nil {
|
|
return "", err
|
|
}
|
|
if m == nil || generation == 0 || actor.UserID == "" || actor.SessionID == "" {
|
|
return "", ErrAuthorizationRequired
|
|
}
|
|
if err := m.authorize(ctx, actor, ConfigOperationRollback, SectionRoot); err != nil {
|
|
return "", err
|
|
}
|
|
if err := m.confirm(ctx, actor, confirmation, ConfigOperationRollback, SectionRoot); err != nil {
|
|
return "", err
|
|
}
|
|
m.mu.RLock()
|
|
currentGeneration := m.current.Generation
|
|
pending := m.pending != nil
|
|
m.mu.RUnlock()
|
|
if pending || generation >= currentGeneration {
|
|
return "", ErrGenerationConflict
|
|
}
|
|
backup, err := m.transactions.LoadBackup(generation)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return "", err
|
|
}
|
|
if err := ValidateRuntimeSecrets(m.vault, backup); err != nil {
|
|
return "", err
|
|
}
|
|
if err := validateRollbackSSL(m.loader.root, backup); err != nil {
|
|
return "", err
|
|
}
|
|
token, err := newRollbackTicketID()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
m.ticketMu.Lock()
|
|
m.cleanupRollbackTicketsLocked(time.Now().UTC())
|
|
m.rollbackTickets[token] = rollbackTicket{ActorID: actor.UserID, SessionID: actor.SessionID, TargetGeneration: generation, ExpiresAt: time.Now().UTC().Add(5 * time.Minute)}
|
|
m.ticketMu.Unlock()
|
|
return token, nil
|
|
}
|
|
|
|
func (m *ConfigManager) RollbackWithTicket(ctx context.Context, token string, actor AuditActor) (*ApplyResult, error) {
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if m == nil || token == "" || actor.UserID == "" || actor.SessionID == "" {
|
|
return nil, ErrAuthorizationRequired
|
|
}
|
|
m.ticketMu.Lock()
|
|
now := time.Now().UTC()
|
|
m.cleanupRollbackTicketsLocked(now)
|
|
ticket, ok := m.rollbackTickets[token]
|
|
if !ok || ticket.ActorID != actor.UserID || ticket.SessionID != actor.SessionID || !now.Before(ticket.ExpiresAt) {
|
|
m.ticketMu.Unlock()
|
|
return nil, ErrAuthorizationRequired
|
|
}
|
|
if ticket.InFlight {
|
|
m.ticketMu.Unlock()
|
|
return nil, ErrRollbackInProgress
|
|
}
|
|
ticket.InFlight = true
|
|
m.rollbackTickets[token] = ticket
|
|
m.ticketMu.Unlock()
|
|
|
|
result, applied, err := m.rollbackWithState(ctx, ticket.TargetGeneration, actor, StepUpConfirmation{Confirmed: true, Method: "ticket"})
|
|
m.ticketMu.Lock()
|
|
if err == nil || applied || !rollbackTicketRetryable(err) {
|
|
delete(m.rollbackTickets, token)
|
|
} else if current, exists := m.rollbackTickets[token]; exists {
|
|
current.InFlight = false
|
|
m.rollbackTickets[token] = current
|
|
}
|
|
m.ticketMu.Unlock()
|
|
return result, err
|
|
}
|
|
|
|
func rollbackTicketRetryable(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
return errors.Is(err, context.Canceled) ||
|
|
errors.Is(err, context.DeadlineExceeded) ||
|
|
errors.Is(err, ErrRuntimeSnapshotUnavailable)
|
|
}
|
|
|
|
func (m *ConfigManager) publishSnapshot(candidate RuntimeConfig) error {
|
|
if m == nil || m.snapshots == nil {
|
|
return ErrRuntimeSnapshotUnavailable
|
|
}
|
|
if err := m.snapshots.Publish(candidate); err != nil {
|
|
if errors.Is(err, ErrRuntimeSnapshotUnavailable) {
|
|
m.mu.Lock()
|
|
m.status.SetupState = SetupStateDegraded
|
|
m.status.DegradedReasons = []PublicIssue{{
|
|
Code: "RUNTIME_SNAPSHOT_UNAVAILABLE",
|
|
Message: "配置已保存,但运行时配置尚未切换,请重试应用",
|
|
}}
|
|
m.mu.Unlock()
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *ConfigManager) rollback(ctx context.Context, generation uint64, actor AuditActor, confirmation StepUpConfirmation) (*ApplyResult, error) {
|
|
result, _, err := m.rollbackWithState(ctx, generation, actor, confirmation)
|
|
return result, err
|
|
}
|
|
|
|
func (m *ConfigManager) rollbackWithState(ctx context.Context, generation uint64, actor AuditActor, confirmation StepUpConfirmation) (*ApplyResult, bool, error) {
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if m == nil {
|
|
return nil, false, ErrManagerStorage
|
|
}
|
|
if generation == 0 {
|
|
return nil, false, ErrGenerationConflict
|
|
}
|
|
if err := m.authorize(ctx, actor, ConfigOperationRollback, SectionRoot); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if confirmation.Method != "ticket" {
|
|
if err := m.confirm(ctx, actor, confirmation, ConfigOperationRollback, SectionRoot); err != nil {
|
|
return nil, false, err
|
|
}
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, false, err
|
|
}
|
|
m.writeMu.Lock()
|
|
defer m.writeMu.Unlock()
|
|
m.mu.RLock()
|
|
current := cloneRuntimeConfig(m.current)
|
|
pending := m.pending != nil
|
|
m.mu.RUnlock()
|
|
if pending || generation >= current.Generation {
|
|
return nil, false, ErrGenerationConflict
|
|
}
|
|
candidate, err := m.transactions.LoadBackup(generation)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if err := ValidateRuntimeSecrets(m.vault, candidate); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if err := validateRollbackSSL(m.loader.root, candidate); err != nil {
|
|
return nil, false, err
|
|
}
|
|
retainPreviousSSL(m.loader.root, &candidate.SSL.TLS, current.SSL.TLS)
|
|
setCandidateGeneration(&candidate, current.Generation+1)
|
|
candidate.Root.UpdatedAt = time.Now().UTC()
|
|
if err := ValidateRuntimeConfig(candidate, m.options); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if err := m.snapshots.Validate(candidate); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if _, _, err := m.transactions.CreateBackupIfDue(current, time.Now().UTC()); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if err := m.transactions.Commit(current, candidate); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if err := m.publishSnapshot(candidate); err != nil {
|
|
return nil, false, err
|
|
}
|
|
now := time.Now().UTC()
|
|
m.mu.Lock()
|
|
m.current = cloneRuntimeConfig(candidate)
|
|
m.pending = nil
|
|
m.status = ConfigStatus{
|
|
SetupState: candidate.SetupState,
|
|
ActiveGeneration: candidate.Generation,
|
|
LastValidAt: &now,
|
|
PendingRestart: []ServiceName{ServiceDatabase, ServiceHTTP, ServiceSMTP, ServiceEmail, ServiceSSL, ServicePlugins},
|
|
}
|
|
m.mu.Unlock()
|
|
maintainManagedSSL(m.loader.root, candidate.SSL.TLS, current.SSL.TLS)
|
|
if err := m.writeAudit(ctx, ConfigAuditEvent{
|
|
Operation: ConfigOperationRollback,
|
|
Section: SectionRoot,
|
|
ActorID: actor.UserID,
|
|
ActorRole: actor.Role,
|
|
ActorName: actor.Name,
|
|
Changes: AuditChanges(current, candidate),
|
|
FromGeneration: current.Generation,
|
|
ToGeneration: candidate.Generation,
|
|
TargetGeneration: generation,
|
|
ConfirmationMethod: confirmation.Method,
|
|
Result: "success",
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return nil, true, err
|
|
}
|
|
return &ApplyResult{Snapshot: cloneRuntimeConfig(candidate), Generation: candidate.Generation, AppliedAt: now}, true, nil
|
|
}
|
|
|
|
func (m *ConfigManager) authorize(ctx context.Context, actor AuditActor, operation ConfigOperation, section Section) error {
|
|
if m == nil || m.security.Authorize == nil {
|
|
return ErrAuthorizationRequired
|
|
}
|
|
return m.security.Authorize(ctx, actor, operation, section)
|
|
}
|
|
|
|
func (m *ConfigManager) confirm(ctx context.Context, actor AuditActor, confirmation StepUpConfirmation, operation ConfigOperation, section Section) error {
|
|
if m == nil || m.security.Confirm == nil {
|
|
return ErrStepUpRequired
|
|
}
|
|
return m.security.Confirm(ctx, actor, confirmation, operation, section)
|
|
}
|
|
|
|
func (m *ConfigManager) writeAudit(ctx context.Context, event ConfigAuditEvent) error {
|
|
if m == nil || m.security.Audit == nil {
|
|
m.mu.Lock()
|
|
m.status.SetupState = SetupStateDegraded
|
|
m.status.DegradedReasons = []PublicIssue{{Code: "AUDIT_UNAVAILABLE", Message: "配置审计服务不可用"}}
|
|
m.mu.Unlock()
|
|
return ErrAuditWrite
|
|
}
|
|
if err := m.security.Audit.WriteConfigAudit(ctx, event); err != nil {
|
|
m.mu.Lock()
|
|
m.status.SetupState = SetupStateDegraded
|
|
m.status.DegradedReasons = []PublicIssue{{Code: "AUDIT_WRITE_FAILED", Message: "配置审计写入失败"}}
|
|
m.mu.Unlock()
|
|
return errors.Join(ErrAuditWrite, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func changedFields(current, candidate RuntimeConfig, section Section) []string {
|
|
var fields []string
|
|
for _, change := range AuditChanges(current, candidate) {
|
|
fields = append(fields, change.Field)
|
|
}
|
|
return fields
|
|
}
|
|
|
|
func servicesForSection(section Section) []ServiceName {
|
|
switch section {
|
|
case SectionRoot:
|
|
return []ServiceName{ServiceDatabase, ServiceHTTP}
|
|
case SectionSMTP:
|
|
return []ServiceName{ServiceSMTP}
|
|
case SectionEmail:
|
|
return []ServiceName{ServiceEmail}
|
|
case SectionMail:
|
|
return []ServiceName{ServiceSMTP, ServiceEmail}
|
|
case SectionSSL:
|
|
return []ServiceName{ServiceHTTP, ServiceSMTP, ServiceSSL}
|
|
case SectionPlugins:
|
|
return []ServiceName{ServicePlugins}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func newRollbackTicketID() (string, error) {
|
|
value := make([]byte, 32)
|
|
if _, err := rand.Read(value); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(value), nil
|
|
}
|
|
|
|
func (m *ConfigManager) cleanupRollbackTicketsLocked(now time.Time) {
|
|
for token, ticket := range m.rollbackTickets {
|
|
if !now.Before(ticket.ExpiresAt) {
|
|
delete(m.rollbackTickets, token)
|
|
}
|
|
}
|
|
}
|
|
|
|
func contextError(ctx context.Context) error {
|
|
if ctx == nil {
|
|
return nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func applySectionPayload(current RuntimeConfig, section Section, payload json.RawMessage, changes map[string]SecretChange, vault *credential.Vault) (RuntimeConfig, error) {
|
|
if len(payload) == 0 {
|
|
return RuntimeConfig{}, errors.New("configuration payload is empty")
|
|
}
|
|
candidate := cloneRuntimeConfig(current)
|
|
clearRelay := false
|
|
switch section {
|
|
case SectionRoot:
|
|
var value struct {
|
|
RootConfig
|
|
DatabasePasswordStatus json.RawMessage `json:"databasePasswordStatus"`
|
|
}
|
|
if err := ParseStrict(payload, &value); err != nil {
|
|
return RuntimeConfig{}, err
|
|
}
|
|
if value.RootConfig.Database.Password.Configured() {
|
|
return RuntimeConfig{}, ErrInvalidSecretChange
|
|
}
|
|
value.RootConfig.Database.Password = current.Root.Database.Password
|
|
candidate.Root = normalizeRootConfig(value.RootConfig)
|
|
case SectionSMTP:
|
|
var value struct {
|
|
SMTPSectionConfig
|
|
RelayPasswordStatus json.RawMessage `json:"relayPasswordStatus"`
|
|
}
|
|
if err := ParseStrict(payload, &value); err != nil {
|
|
return RuntimeConfig{}, err
|
|
}
|
|
if value.SMTPSectionConfig.Relay.Password.Configured() {
|
|
return RuntimeConfig{}, ErrInvalidSecretChange
|
|
}
|
|
clearRelay = value.SMTPSectionConfig.Relay.Clear
|
|
if clearRelay {
|
|
clearSMTPRelay(&value.SMTPSectionConfig.Relay)
|
|
} else {
|
|
value.SMTPSectionConfig.Relay.Password = current.SMTP.Relay.Password
|
|
}
|
|
candidate.SMTP = value.SMTPSectionConfig
|
|
candidate.Email.Policy.LoginVerificationPolicy = candidate.SMTP.Relay.LoginPolicy
|
|
case SectionEmail:
|
|
var value EmailSectionConfig
|
|
if err := ParseStrict(payload, &value); err != nil {
|
|
return RuntimeConfig{}, err
|
|
}
|
|
candidate.Email = value
|
|
candidate.SMTP.Relay.LoginPolicy = candidate.Email.Policy.LoginVerificationPolicy
|
|
case SectionMail:
|
|
var value struct {
|
|
MailSectionConfig
|
|
RelayPasswordStatus json.RawMessage `json:"relayPasswordStatus"`
|
|
}
|
|
if err := ParseStrict(payload, &value); err != nil {
|
|
return RuntimeConfig{}, err
|
|
}
|
|
if value.MailSectionConfig.Relay.Password.Configured() {
|
|
return RuntimeConfig{}, ErrInvalidSecretChange
|
|
}
|
|
clearRelay = value.MailSectionConfig.Relay.Clear
|
|
if clearRelay {
|
|
clearSMTPRelay(&value.MailSectionConfig.Relay)
|
|
} else {
|
|
value.MailSectionConfig.Relay.Password = current.SMTP.Relay.Password
|
|
}
|
|
candidate.SMTP = SMTPSectionConfig{
|
|
Meta: value.Meta,
|
|
Listener: value.Listener,
|
|
Relay: value.Relay,
|
|
}
|
|
candidate.Email = EmailSectionConfig{
|
|
Meta: value.Meta,
|
|
IMAP: value.IMAP,
|
|
POP3: value.POP3,
|
|
Policy: value.Policy,
|
|
}
|
|
candidate.Email.Policy.LoginVerificationPolicy = candidate.SMTP.Relay.LoginPolicy
|
|
case SectionSSL:
|
|
var value struct {
|
|
SSLSectionConfig
|
|
DNSAccessKeyStatus json.RawMessage `json:"dnsAccessKeyStatus"`
|
|
DNSSecretKeyStatus json.RawMessage `json:"dnsSecretKeyStatus"`
|
|
}
|
|
if err := ParseStrict(payload, &value); err != nil {
|
|
return RuntimeConfig{}, err
|
|
}
|
|
if storedSecretHasMaterial(value.TLS.DNSAccessKey) || storedSecretHasMaterial(value.TLS.DNSSecretKey) {
|
|
return RuntimeConfig{}, ErrInvalidSecretChange
|
|
}
|
|
value.TLS.DNSAccessKey = current.SSL.TLS.DNSAccessKey
|
|
value.TLS.DNSSecretKey = current.SSL.TLS.DNSSecretKey
|
|
candidate.SSL = value.SSLSectionConfig
|
|
case SectionPlugins:
|
|
var value PluginsSectionConfig
|
|
if err := ParseStrict(payload, &value); err != nil {
|
|
return RuntimeConfig{}, err
|
|
}
|
|
for _, plugin := range value.Plugins {
|
|
if storedSecretHasMaterial(plugin.Secret) {
|
|
return RuntimeConfig{}, ErrInvalidSecretChange
|
|
}
|
|
}
|
|
for _, plugin := range current.Plugins.Plugins {
|
|
if storedSecretHasMaterial(plugin.Secret) {
|
|
return RuntimeConfig{}, ErrPluginSecretMigrationRequired
|
|
}
|
|
}
|
|
candidate.Plugins = value
|
|
default:
|
|
return RuntimeConfig{}, ErrInvalidSection
|
|
}
|
|
if err := applySecretChanges(&candidate, section, changes, vault); err != nil {
|
|
return RuntimeConfig{}, err
|
|
}
|
|
if clearRelay {
|
|
clearSMTPRelay(&candidate.SMTP.Relay)
|
|
}
|
|
candidate.SetupState = candidate.Root.SetupState
|
|
return candidate, nil
|
|
}
|
|
|
|
func clearSMTPRelay(relay *SMTPConfig) {
|
|
if relay == nil {
|
|
return
|
|
}
|
|
relay.RelayEnabled = false
|
|
relay.RelayHost = ""
|
|
relay.RelayPort = 0
|
|
relay.Username = ""
|
|
relay.Password = StoredSecret{}
|
|
relay.Security = ""
|
|
relay.From = ""
|
|
relay.FromDomain = ""
|
|
relay.Clear = false
|
|
}
|
|
|
|
func setCandidateGeneration(candidate *RuntimeConfig, generation uint64) {
|
|
if candidate == nil {
|
|
return
|
|
}
|
|
candidate.Generation = generation
|
|
candidate.Root.Generation = generation
|
|
candidate.Root.Sections = sectionReferencesForRuntime(*candidate)
|
|
candidate.SMTP.Meta.Generation = generation
|
|
candidate.Email.Meta.Generation = generation
|
|
candidate.SSL.Meta.Generation = generation
|
|
candidate.Plugins.Meta.Generation = generation
|
|
}
|
|
|
|
func sectionReferencesForRuntime(runtime RuntimeConfig) map[Section]SectionRef {
|
|
result := make(map[Section]SectionRef, len(requiredPartitionSections))
|
|
for _, section := range requiredPartitionSections {
|
|
file, _ := FixedSectionFile(section)
|
|
schema, _ := FixedSectionSchema(section)
|
|
var data []byte
|
|
switch section {
|
|
case SectionSMTP:
|
|
data, _ = json.Marshal(runtime.SMTP)
|
|
case SectionEmail:
|
|
data, _ = json.Marshal(runtime.Email)
|
|
case SectionSSL:
|
|
data, _ = json.Marshal(runtime.SSL)
|
|
case SectionPlugins:
|
|
data, _ = json.Marshal(runtime.Plugins)
|
|
}
|
|
result[section] = SectionRef{File: file, Schema: schema, Generation: runtime.Generation, SHA256: SectionDigest(data)}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func applySecretChanges(candidate *RuntimeConfig, section Section, changes map[string]SecretChange, vault *credential.Vault) error {
|
|
if len(changes) == 0 {
|
|
return nil
|
|
}
|
|
if candidate == nil || vault == nil {
|
|
return ErrSecretUnavailable
|
|
}
|
|
now := time.Now().UTC()
|
|
for name, change := range changes {
|
|
switch {
|
|
case section == SectionRoot && name == "database.password":
|
|
stored, err := ApplySecretChange(vault, candidate.Root.Database.Password, change, SecretPolicy{Required: candidate.Root.Database.Type != DatabaseTypeSQLite, AllowClear: false, MaxLength: 4096}, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
candidate.Root.Database.Password = stored
|
|
case (section == SectionSMTP || section == SectionMail) && name == "relay.password":
|
|
stored, err := ApplySecretChange(vault, candidate.SMTP.Relay.Password, change, SecretPolicy{Required: candidate.SMTP.Relay.Username != "", AllowClear: candidate.SMTP.Relay.Username == "", MaxLength: 4096}, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
candidate.SMTP.Relay.Password = stored
|
|
case section == SectionSSL && (name == "tls.dnsAccessKey" || name == "tls.dnsSecretKey"):
|
|
target := &candidate.SSL.TLS.DNSAccessKey
|
|
if name == "tls.dnsSecretKey" {
|
|
target = &candidate.SSL.TLS.DNSSecretKey
|
|
}
|
|
required := candidate.SSL.TLS.DNSProvider != ""
|
|
stored, err := ApplySecretChange(vault, *target, change, SecretPolicy{Required: required, AllowClear: !required, MaxLength: 4096}, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*target = stored
|
|
default:
|
|
return ErrInvalidSecretChange
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func storedSecretHasMaterial(secret StoredSecret) bool {
|
|
return secret.Ciphertext != "" || secret.Version != 0 || !secret.UpdatedAt.IsZero()
|
|
}
|
|
|
|
// MigrateLegacyPluginSecrets moves compatibility-only plugin secrets out of plugins.json.
|
|
// The importer must persist all received values atomically before this method commits the new configuration generation.
|
|
func (m *ConfigManager) MigrateLegacyPluginSecrets(ctx context.Context, importer LegacyPluginSecretImporter) (bool, error) {
|
|
if err := contextError(ctx); err != nil {
|
|
return false, err
|
|
}
|
|
if m == nil || importer == nil || m.vault == nil {
|
|
return false, ErrPluginSecretMigrationRequired
|
|
}
|
|
m.writeMu.Lock()
|
|
defer m.writeMu.Unlock()
|
|
m.mu.RLock()
|
|
current := cloneRuntimeConfig(m.current)
|
|
pending := m.pending != nil
|
|
m.mu.RUnlock()
|
|
if pending {
|
|
return false, ErrGenerationConflict
|
|
}
|
|
|
|
values := make([]LegacyPluginSecret, 0, len(current.Plugins.Plugins))
|
|
for logicalID, instance := range current.Plugins.Plugins {
|
|
if err := contextError(ctx); err != nil {
|
|
return false, err
|
|
}
|
|
if !storedSecretHasMaterial(instance.Secret) {
|
|
continue
|
|
}
|
|
fieldName, ok := legacyPluginSecretField(logicalID)
|
|
if !ok || !instance.Secret.Configured() {
|
|
return false, ErrPluginSecretMigrationRequired
|
|
}
|
|
plaintext, err := m.vault.Decrypt(instance.Secret.Version, instance.Secret.Ciphertext)
|
|
if err != nil || plaintext == "" {
|
|
return false, ErrPluginSecretMigrationRequired
|
|
}
|
|
values = append(values, LegacyPluginSecret{PluginID: logicalID, FieldName: fieldName, Plaintext: plaintext})
|
|
}
|
|
if len(values) == 0 {
|
|
return false, nil
|
|
}
|
|
if err := importer(ctx, values); err != nil {
|
|
return false, err
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
candidate := cloneRuntimeConfig(current)
|
|
for logicalID, instance := range candidate.Plugins.Plugins {
|
|
instance.Secret = StoredSecret{}
|
|
candidate.Plugins.Plugins[logicalID] = instance
|
|
}
|
|
setCandidateGeneration(&candidate, current.Generation+1)
|
|
if err := ValidateRuntimeConfig(candidate, m.options); err != nil {
|
|
return false, err
|
|
}
|
|
if err := m.snapshots.Validate(candidate); err != nil {
|
|
return false, err
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return false, err
|
|
}
|
|
if _, _, err := m.transactions.CreateBackupIfDue(current, time.Now().UTC()); err != nil {
|
|
return false, err
|
|
}
|
|
if err := contextError(ctx); err != nil {
|
|
return false, err
|
|
}
|
|
if err := m.transactions.Commit(current, candidate); err != nil {
|
|
return false, err
|
|
}
|
|
if err := m.publishSnapshot(candidate); err != nil {
|
|
return false, err
|
|
}
|
|
now := time.Now().UTC()
|
|
m.mu.Lock()
|
|
m.current = cloneRuntimeConfig(candidate)
|
|
m.pending = nil
|
|
m.status = ConfigStatus{SetupState: candidate.SetupState, ActiveGeneration: candidate.Generation, LastValidAt: &now, PendingRestart: []ServiceName{ServicePlugins}}
|
|
m.mu.Unlock()
|
|
if err := m.writeAudit(ctx, ConfigAuditEvent{
|
|
Operation: ConfigOperationMigration,
|
|
Section: SectionPlugins,
|
|
ActorID: "system",
|
|
ActorRole: "system",
|
|
FromGeneration: current.Generation,
|
|
ToGeneration: candidate.Generation,
|
|
ChangedFields: []string{"plugins.legacySecret"},
|
|
Changes: AuditChanges(current, candidate),
|
|
Result: "success",
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func legacyPluginSecretField(logicalID string) (string, bool) {
|
|
switch logicalID {
|
|
case "wechat_push":
|
|
return "appSecret", true
|
|
case "telegram_push":
|
|
return "botToken", true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func requiresSecretStepUp(changes map[string]SecretChange) bool {
|
|
for _, change := range changes {
|
|
if change.Operation == SecretClear || change.Operation == SecretReplace {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validStepUpConfirmation(confirmation StepUpConfirmation) bool {
|
|
if !confirmation.Confirmed {
|
|
return false
|
|
}
|
|
method := strings.ToLower(strings.TrimSpace(confirmation.Method))
|
|
return method == "recent_auth" || method == "webauthn"
|
|
}
|