Smail/backend/config/state_store.go
smail 200a399b4d
Some checks are pending
Security Checks / security (push) Waiting to run
收紧证书与配置状态文件访问
2026-09-22 03:35:32 +08:00

351 lines
10 KiB
Go

package config
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"time"
"git.pcux.cn/xuans/Smail/utils/atomicfile"
fileutil "git.pcux.cn/xuans/Smail/utils/file"
"git.pcux.cn/xuans/Smail/utils/runtimepath"
)
// 配置历史保存在所选业务数据库;事务字段仅用于读取旧版独立状态库。
type stateRecord struct {
Transaction *TransactionManifest `json:"transaction,omitempty"`
Backup *ConfigBackupManifest `json:"backup,omitempty"`
Original map[string][]byte `json:"original,omitempty"`
Candidate map[string][]byte `json:"candidate,omitempty"`
}
func (s *TransactionStore) lockState() (func(), error) {
directory, err := runtimepath.Directory(s.root)
if err != nil {
return nil, err
}
filename := filepath.Join(directory, ".config-state.lock")
if info, err := os.Lstat(filename); err == nil && !info.Mode().IsRegular() {
return nil, ErrManagerStorage
} else if err != nil && !os.IsNotExist(err) {
return nil, err
}
file, err := fileutil.OpenRegularFile(filename, os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return nil, err
}
unlock, err := lockStateFile(file)
if err != nil {
return nil, errors.Join(errors.New("配置状态正在被其他进程使用,请稍后重试"), file.Close())
}
return func() { releaseConfigLease(unlock, file) }, nil
}
func (s *TransactionStore) saveState(kind, id string, record stateRecord) error {
if !transactionIDPattern.MatchString(id) {
return ErrTransactionInvalid
}
data, err := json.Marshal(record)
if err != nil {
return err
}
db, err := s.openState()
if err != nil {
return err
}
defer db.Close()
_, err = db.Exec(db.upsert(), kind, id, data, SectionDigest(data))
return err
}
func validateStateFiles(files map[string][]byte, digests map[string]string) error {
if !validFileDigestSet(digests) || len(files) != len(digests) {
return ErrTransactionInvalid
}
for name, digest := range digests {
if len(files[name]) == 0 || len(files[name]) > maxConfigFileSize || SectionDigest(files[name]) != digest {
return ErrConfigDigestMismatch
}
}
return nil
}
func (s *TransactionStore) stateRecords(kind string) ([]stateRecord, error) {
db, err := s.openState()
if err != nil {
return nil, err
}
defer db.Close()
var oversized int
if err := db.QueryRow("SELECT count(*) FROM config_records WHERE kind=? AND length(payload)>?", kind, 16*maxConfigFileSize).Scan(&oversized); err != nil || oversized != 0 {
return nil, errors.Join(ErrTransactionInvalid, err)
}
rows, err := db.Query("SELECT id,payload,digest FROM config_records WHERE kind=? ORDER BY id", kind)
if err != nil {
return nil, err
}
defer rows.Close()
var result []stateRecord
for rows.Next() {
var id, digest string
var data []byte
if err := rows.Scan(&id, &data, &digest); err != nil {
return nil, err
}
if !transactionIDPattern.MatchString(id) || len(data) > 16*maxConfigFileSize || SectionDigest(data) != digest {
return nil, ErrConfigDigestMismatch
}
var record stateRecord
if err := ParseStrict(data, &record); err != nil {
return nil, err
}
switch kind {
case "transaction":
if record.Transaction == nil || record.Transaction.ID != id || record.Backup != nil {
return nil, ErrTransactionInvalid
}
if err := validateTransactionManifest(*record.Transaction); err != nil {
return nil, err
}
if err := validateStateFiles(record.Original, record.Transaction.OriginalFiles); err != nil {
return nil, err
}
if err := validateStateFiles(record.Candidate, record.Transaction.CandidateFiles); err != nil {
return nil, err
}
for _, item := range []struct {
files map[string][]byte
generation uint64
}{
{record.Original, record.Transaction.FromGeneration},
{record.Candidate, record.Transaction.ToGeneration},
} {
config, err := parseRuntimeFiles(item.files, s.validation)
if err != nil || config.Generation != item.generation {
return nil, errors.Join(ErrTransactionInvalid, err)
}
}
case "backup":
if record.Backup == nil || record.Backup.ID != id || record.Transaction != nil || record.Backup.CreatedAt.IsZero() {
return nil, ErrTransactionInvalid
}
if err := validateStateFiles(record.Original, record.Backup.Files); err != nil {
return nil, err
}
config, err := parseRuntimeFiles(record.Original, s.validation)
if err != nil {
return nil, err
}
if config.Generation != record.Backup.Generation {
return nil, ErrConfigGenerationMismatch
}
default:
return nil, ErrTransactionInvalid
}
result = append(result, record)
}
return result, rows.Err()
}
func (s *TransactionStore) removeState(kind, id string) error {
db, err := s.openState()
if err != nil {
return err
}
defer db.Close()
_, err = db.Exec("DELETE FROM config_records WHERE kind=? AND id=?", kind, id)
return err
}
func writeStateLive(directory string, files map[string][]byte, rootOnly bool) error {
for _, name := range transactionFileNames() {
if (name == "config.json") != rootOnly {
continue
}
target := filepath.Join(directory, name)
if info, err := os.Lstat(target); err == nil && !info.Mode().IsRegular() {
return ErrManagerStorage
} else if err != nil && !os.IsNotExist(err) {
return err
}
if err := atomicfile.Write(target, files[name], 0600); err != nil {
return err
}
}
return atomicfile.SyncDirectory(directory)
}
func (s *TransactionStore) commitState(current, candidate RuntimeConfig) error {
unlock, err := s.lockState()
if err != nil {
return err
}
defer unlock()
if current.Generation == 0 || candidate.Generation != current.Generation+1 {
return ErrGenerationConflict
}
if err := ValidateRuntimeConfig(candidate, s.validation); err != nil {
return err
}
if records, err := s.stateRecords("transaction"); err != nil || len(records) != 0 {
return errors.Join(ErrConfigRecoveryRequired, err)
}
live, err := NewConfigLoader(s.root, ConfigLoaderOptions{Validation: s.validation}).Load()
if err != nil || live.Generation != current.Generation {
return errors.Join(ErrGenerationConflict, err)
}
original, err := marshalRuntimeFiles(current)
if err != nil {
return err
}
files, err := marshalRuntimeFiles(candidate)
if err != nil {
return err
}
directory, err := secureManagerConfigDirectory(s.root)
if err != nil {
return err
}
id, err := newTransactionID()
if err != nil {
return err
}
manifest := TransactionManifest{
ID: id, FromGeneration: current.Generation, ToGeneration: candidate.Generation,
Sections: append([]Section(nil), requiredPartitionSections...), OriginalFiles: digestFiles(original),
CandidateFiles: digestFiles(files), Phase: TransactionPrepared, CreatedAt: time.Now().UTC(),
}
record := stateRecord{Transaction: &manifest, Original: original, Candidate: files}
persist := func(phase TransactionPhase) error {
manifest.Phase = phase
if err := s.saveState("transaction", id, record); err != nil {
return err
}
return s.beforePhase(phase)
}
if err := persist(TransactionPrepared); err != nil {
return err
}
if err := writeStateLive(directory, files, false); err != nil {
return err
}
if err := persist(TransactionSectionsCommitted); err != nil {
return err
}
if err := writeStateLive(directory, files, true); err != nil {
return err
}
if err := persist(TransactionRootCommitted); err != nil {
return err
}
loaded, err := NewConfigLoader(s.root, ConfigLoaderOptions{Validation: s.validation}).Load()
if err != nil || loaded.Generation != candidate.Generation {
return errors.Join(ErrConfigGenerationMismatch, err)
}
if err := persist(TransactionVerified); err != nil {
return err
}
return s.removeState("transaction", id)
}
func (s *TransactionStore) recoverState() error {
unlock, err := s.lockState()
if err != nil {
return err
}
defer unlock()
return s.recoverStateLocked()
}
func (s *TransactionStore) recoverStateLocked() error {
if entries, err := os.ReadDir(filepath.Join(s.root, "data", "transaction")); err == nil && len(entries) != 0 {
return ErrConfigRecoveryRequired
} else if err != nil && !os.IsNotExist(err) {
return err
}
records, err := s.stateRecords("transaction")
if err != nil {
return err
}
if len(records) > 1 {
return ErrTransactionInvalid
}
for _, record := range records {
manifest := record.Transaction
loaded, err := NewConfigLoader(s.root, ConfigLoaderOptions{Validation: s.validation}).Load()
if err == nil && loaded.Generation > manifest.ToGeneration {
return ErrConfigGenerationMismatch
}
if err != nil || loaded.Generation < manifest.ToGeneration {
if _, err := parseRuntimeFiles(record.Original, s.validation); err != nil {
return err
}
directory, err := secureManagerConfigDirectory(s.root)
if err != nil {
return err
}
if err := writeStateLive(directory, record.Original, false); err != nil {
return err
}
if err := writeStateLive(directory, record.Original, true); err != nil {
return err
}
if _, err := NewConfigLoader(s.root, ConfigLoaderOptions{Validation: s.validation}).Load(); err != nil {
return err
}
}
if err := s.removeState("transaction", manifest.ID); err != nil {
return err
}
}
return nil
}
func (s *TransactionStore) stateBackups() ([]stateRecord, error) {
items, err := s.stateRecords("backup")
sort.Slice(items, func(i, j int) bool {
if items[i].Backup.CreatedAt.Equal(items[j].Backup.CreatedAt) {
return items[i].Backup.ID > items[j].Backup.ID
}
return items[i].Backup.CreatedAt.After(items[j].Backup.CreatedAt)
})
return items, err
}
func (s *TransactionStore) createStateBackup(config RuntimeConfig, now time.Time) (string, error) {
unlock, err := s.lockState()
if err != nil {
return "", err
}
defer unlock()
if err := ValidateRuntimeConfig(config, s.validation); err != nil {
return "", err
}
files, err := marshalRuntimeFiles(config)
if err != nil {
return "", err
}
id, err := newTransactionID()
if err != nil {
return "", err
}
manifest := ConfigBackupManifest{ID: id, Generation: config.Generation, CreatedAt: now, Files: digestFiles(files)}
if err := s.saveState("backup", id, stateRecord{Backup: &manifest, Original: files}); err != nil {
return "", err
}
items, err := s.stateBackups()
if err != nil {
return "", err
}
for index, item := range items {
if index > 0 && (index >= configBackupLimit || now.Sub(item.Backup.CreatedAt) > configBackupMaxAge) {
if err := s.removeState("backup", item.Backup.ID); err != nil {
return "", err
}
}
}
return id, nil
}