Smail/server/config/manager.go

368 lines
11 KiB
Go

package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Smail/utils/atomicfile"
fileutil "git.pcux.cn/xuans/Smail/utils/file"
)
var (
ErrGenerationConflict = errors.New("configuration generation conflict")
ErrNoBackup = errors.New("configuration backup not found")
ErrManagerStorage = errors.New("configuration manager storage unavailable")
)
type RuntimeSnapshot struct {
Config RootConfig
Generation uint64
}
type Manager struct {
mu sync.RWMutex
current RootConfig
generation uint64
validator func(RootConfig) error
}
func NewManager(initial RootConfig) (*Manager, error) {
initial = normalizeRootConfig(initial)
if err := ValidateRootConfig(initial); err != nil {
return nil, err
}
return &Manager{current: cloneRootConfig(initial), generation: initial.Generation, validator: ValidateRootConfig}, nil
}
func LoadManager(root string, fallback RootConfig) (*Manager, error) {
if root == "" {
return nil, ErrManagerStorage
}
directory, err := secureManagerConfigDirectory(root)
if err != nil {
return nil, err
}
data, err := readManagerConfigFile(directory, "config.json")
if err != nil {
if os.IsNotExist(err) {
return NewManager(fallback)
}
return nil, ErrManagerStorage
}
var loaded RootConfig
if err := ParseStrict(data, &loaded); err != nil {
return nil, err
}
requiresRewrite := loaded.SchemaVersion == 0 || loaded.Generation == 0 || loaded.Version != 0
manager, err := NewManager(loaded)
if err != nil {
return nil, err
}
if requiresRewrite {
if err := writeRootConfig(root, manager.current); err != nil {
return nil, err
}
}
return manager, nil
}
func (m *Manager) Snapshot() RuntimeSnapshot {
m.mu.RLock()
defer m.mu.RUnlock()
current := cloneRootConfig(m.current)
current.Generation = m.generation
return RuntimeSnapshot{Config: current, Generation: m.generation}
}
func (m *Manager) Preview(candidate RootConfig, expected uint64) error {
candidate = normalizeRootConfig(candidate)
m.mu.RLock()
generation := m.generation
m.mu.RUnlock()
if expected != generation {
return ErrGenerationConflict
}
candidate.SchemaVersion = CurrentSchemaVersion
candidate.Generation = generation + 1
candidate.Version = 0
return m.validator(candidate)
}
func (m *Manager) Apply(candidate RootConfig, expected uint64) (RuntimeSnapshot, error) {
candidate = normalizeRootConfig(candidate)
m.mu.Lock()
defer m.mu.Unlock()
if expected != m.generation {
return RuntimeSnapshot{}, ErrGenerationConflict
}
candidate.SchemaVersion = CurrentSchemaVersion
candidate.Generation = m.generation + 1
candidate.Version = 0
candidate.UpdatedAt = time.Now().UTC()
if err := m.validator(candidate); err != nil {
return RuntimeSnapshot{}, err
}
m.current = cloneRootConfig(candidate)
m.generation = candidate.Generation
return RuntimeSnapshot{Config: cloneRootConfig(m.current), Generation: m.generation}, nil
}
func cloneRootConfig(source RootConfig) RootConfig {
clone := source
clone.Security.WebAuthnRPOrigins = append([]string(nil), source.Security.WebAuthnRPOrigins...)
clone.Domains.Accepted = append([]string(nil), source.Domains.Accepted...)
clone.Domains.AccessHosts = append([]string(nil), source.Domains.AccessHosts...)
if source.Sections != nil {
clone.Sections = make(map[Section]SectionRef, len(source.Sections))
for section, reference := range source.Sections {
clone.Sections[section] = reference
}
}
if source.Plugins != nil {
clone.Plugins = make([]PluginConfig, len(source.Plugins))
for i, plugin := range source.Plugins {
clone.Plugins[i] = plugin
if plugin.NetworkAllowlist != nil {
clone.Plugins[i].NetworkAllowlist = append([]string(nil), plugin.NetworkAllowlist...)
}
}
}
return clone
}
func BackupConfig(root string, snapshot RuntimeSnapshot, now time.Time) (string, error) {
if root == "" || snapshot.Generation == 0 {
return "", errors.New("invalid backup request")
}
if now.IsZero() {
now = time.Now()
}
if snapshot.Config.Generation != snapshot.Generation {
return "", errors.New("inconsistent backup generation")
}
directory, err := secureBackupDirectory(root)
if err != nil {
return "", err
}
if err := pruneBackups(directory, now); err != nil {
return "", err
}
data, err := json.Marshal(snapshot)
if err != nil {
return "", err
}
name := fmt.Sprintf("config-%020d-%s.json", snapshot.Generation, now.UTC().Format("20060102T150405.000000000Z"))
path := filepath.Join(directory, name)
if _, err := os.Lstat(path); err == nil {
return "", ErrManagerStorage
} else if !os.IsNotExist(err) {
return "", err
}
if err := atomicfile.Write(path, data, 0600); err != nil {
return "", err
}
if err := pruneBackups(directory, now); err != nil {
return "", err
}
return path, nil
}
func LoadBackup(path string) (RuntimeSnapshot, error) {
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() <= 0 || info.Size() > maxConfigFileSize {
return RuntimeSnapshot{}, ErrNoBackup
}
data, err := fileutil.ReadRegularFile(path, maxConfigFileSize)
if err != nil {
return RuntimeSnapshot{}, ErrNoBackup
}
var snapshot RuntimeSnapshot
if err := ParseStrict(data, &snapshot); err != nil || snapshot.Generation == 0 || snapshot.Config.Generation != snapshot.Generation {
return RuntimeSnapshot{}, ErrNoBackup
}
if err := ValidateRootConfig(snapshot.Config); err != nil {
return RuntimeSnapshot{}, err
}
return snapshot, nil
}
func pruneBackups(directory string, now time.Time) error {
entries, err := os.ReadDir(directory)
if err != nil {
return err
}
type item struct {
path string
mod time.Time
}
items := make([]item, 0, len(entries))
for _, entry := range entries {
if entry.Type()&os.ModeSymlink != 0 {
return ErrManagerStorage
}
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" || !strings.HasPrefix(entry.Name(), "config-") {
continue
}
info, err := entry.Info()
if err != nil || !info.Mode().IsRegular() {
return ErrManagerStorage
}
if now.Sub(info.ModTime()) > 30*24*time.Hour {
if err := os.Remove(filepath.Join(directory, entry.Name())); err != nil {
return err
}
continue
}
items = append(items, item{path: filepath.Join(directory, entry.Name()), mod: info.ModTime()})
}
sort.Slice(items, func(i, j int) bool {
if items[i].mod.Equal(items[j].mod) {
return items[i].path > items[j].path
}
return items[i].mod.After(items[j].mod)
})
if len(items) > 4 {
for _, old := range items[4:] {
if err := os.Remove(old.path); err != nil {
return err
}
}
}
return nil
}
func (m *Manager) ApplyPersisted(root string, candidate RootConfig, expected uint64, now time.Time) (RuntimeSnapshot, string, error) {
if m == nil || root == "" {
return RuntimeSnapshot{}, "", ErrManagerStorage
}
if now.IsZero() {
now = time.Now()
}
candidate = normalizeRootConfig(candidate)
if err := m.validator(candidate); err != nil {
return RuntimeSnapshot{}, "", err
}
m.mu.Lock()
defer m.mu.Unlock()
if expected != m.generation {
return RuntimeSnapshot{}, "", ErrGenerationConflict
}
previous := RuntimeSnapshot{Config: cloneRootConfig(m.current), Generation: m.generation}
backupPath, err := BackupConfig(root, previous, now)
if err != nil {
return RuntimeSnapshot{}, "", err
}
next := cloneRootConfig(candidate)
next.SchemaVersion = CurrentSchemaVersion
next.Generation = m.generation + 1
next.Version = 0
next.UpdatedAt = now.UTC()
if err := writeRootConfig(root, next); err != nil {
return RuntimeSnapshot{}, "", err
}
m.current = next
m.generation = next.Generation
return RuntimeSnapshot{Config: cloneRootConfig(next), Generation: m.generation}, backupPath, nil
}
func (m *Manager) RollbackPersisted(root, backupPath string, expected uint64, now time.Time) (RuntimeSnapshot, string, error) {
directory, err := secureBackupDirectory(root)
if err != nil {
return RuntimeSnapshot{}, "", err
}
cleanPath, err := filepath.Abs(filepath.Clean(backupPath))
if err != nil {
return RuntimeSnapshot{}, "", ErrNoBackup
}
cleanDirectory, err := filepath.Abs(directory)
if err != nil || filepath.Dir(cleanPath) != cleanDirectory {
return RuntimeSnapshot{}, "", ErrNoBackup
}
snapshot, err := LoadBackup(cleanPath)
if err != nil {
return RuntimeSnapshot{}, "", err
}
return m.ApplyPersisted(root, snapshot.Config, expected, now)
}
func writeRootConfig(root string, value RootConfig) error {
directory, err := secureManagerConfigDirectory(root)
if err != nil {
return err
}
value = normalizeRootConfig(value)
data, err := json.Marshal(value)
if err != nil {
return err
}
if info, statErr := os.Lstat(filepath.Join(directory, "config.json")); statErr == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return ErrManagerStorage
}
} else if !os.IsNotExist(statErr) {
return statErr
}
return atomicfile.Write(filepath.Join(directory, "config.json"), data, 0600)
}
func secureManagerConfigDirectory(root string) (string, error) {
if root == "" {
return "", ErrManagerStorage
}
directory := filepath.Join(root, "config")
if err := os.MkdirAll(directory, 0700); err != nil {
return "", err
}
info, err := os.Lstat(directory)
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return "", ErrManagerStorage
}
// 鐩綍鏉冮檺 0700 鏄厤缃枃浠剁殑鏈€灏忔潈闄愯姹傦紱G302 灏嗙洰褰曡鍒や负鏂囦欢銆? // #nosec G302
if err := os.Chmod(directory, 0700); err != nil {
return "", err
}
return directory, nil
}
func secureBackupDirectory(root string) (string, error) {
configDirectory, err := secureManagerConfigDirectory(root)
if err != nil {
return "", err
}
directory := filepath.Join(configDirectory, "backups")
if err := os.MkdirAll(directory, 0700); err != nil {
return "", err
}
info, err := os.Lstat(directory)
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return "", ErrManagerStorage
}
// 澶囦唤鐩綍鍙厑璁告湇鍔¤处鎴疯闂紱G302 灏嗙洰褰曡鍒や负鏂囦欢銆? // #nosec G302
if err := os.Chmod(directory, 0700); err != nil {
return "", err
}
return directory, nil
}
func readManagerConfigFile(directory, name string) ([]byte, error) {
if filepath.Base(name) != name || name == "" {
return nil, ErrManagerStorage
}
path := filepath.Join(directory, name)
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxConfigFileSize {
return nil, ErrManagerStorage
}
return fileutil.ReadRegularFile(path, maxConfigFileSize)
}