backup: pre-hardening baseline

This commit is contained in:
backup 2026-09-02 11:20:31 -05:00
commit 9e4c612dcb
57 changed files with 10393 additions and 0 deletions

View file

@ -0,0 +1,385 @@
package file
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
)
type Service struct {
configDir string
db *sql.DB
locks *FileLocks
}
func NewService(configDir string, db *sql.DB) (*Service, error) {
abs, err := filepath.Abs(configDir)
if err != nil {
return nil, fmt.Errorf("resolve config dir: %w", err)
}
if err := os.MkdirAll(abs, 0o755); err != nil {
return nil, fmt.Errorf("create config dir: %w", err)
}
// Ensure backups dir exists
if err := os.MkdirAll(filepath.Join(abs, "backups"), 0o755); err != nil {
return nil, fmt.Errorf("create backups dir: %w", err)
}
return &Service{configDir: abs, db: db, locks: NewFileLocks()}, nil
}
func (s *Service) ConfigDir() string { return s.configDir }
// sanitizedPath returns absolute path inside configDir, after validating filename
func (s *Service) sanitizedPath(filename string) (string, error) {
if err := ValidateFilename(filename); err != nil {
return "", err
}
// No path separators already validated, but double-check
clean := filepath.Base(filename)
p := filepath.Join(s.configDir, clean)
// Ensure p is inside configDir
if !strings.HasPrefix(p, s.configDir+string(os.PathSeparator)) && p != s.configDir {
return "", fmt.Errorf("invalid path")
}
return p, nil
}
// FileMeta for listing without exposing paths
type FileMeta struct {
Filename string `json:"filename"`
Size int64 `json:"size"`
ModTime string `json:"modTime"` // RFC3339
Valid bool `json:"valid"`
ValidationErr string `json:"validationErr,omitempty"`
LastAction string `json:"lastAction,omitempty"` // apply/rollback
LastActionTime string `json:"lastActionTime,omitempty"`
}
// ListFiles lists files in configDir (excluding backups subdir and temp files)
func (s *Service) ListFiles() ([]string, error) {
entries, err := os.ReadDir(s.configDir)
if err != nil {
return nil, err
}
var files []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.Contains(name, ".tmp.") {
continue
}
if strings.HasSuffix(strings.ToLower(name), ".yml") || strings.HasSuffix(strings.ToLower(name), ".yaml") || strings.HasSuffix(strings.ToLower(name), ".toml") {
files = append(files, name)
}
}
return files, nil
}
// ListFilesWithMeta returns enriched metadata without exposing paths
func (s *Service) ListFilesWithMeta() ([]FileMeta, error) {
entries, err := os.ReadDir(s.configDir)
if err != nil {
return nil, err
}
var out []FileMeta
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.Contains(name, ".tmp.") {
continue
}
lower := strings.ToLower(name)
if !(strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".toml")) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
meta := FileMeta{
Filename: name,
Size: info.Size(),
ModTime: info.ModTime().UTC().Format(time.RFC3339),
Valid: true,
}
// Validate content for status
if b, err := os.ReadFile(filepath.Join(s.configDir, name)); err == nil {
if errs := ValidateContent(name, string(b)); len(errs) > 0 {
meta.Valid = false
meta.ValidationErr = errs[0].Error()
}
} else {
meta.Valid = false
meta.ValidationErr = err.Error()
}
// Last action from backups
var reason, createdAt string
err = s.db.QueryRow(`SELECT reason, created_at FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, name).Scan(&reason, &createdAt)
if err == nil {
meta.LastAction = reason
meta.LastActionTime = createdAt
}
out = append(out, meta)
}
return out, nil
}
// ReadFile reads raw content of a file in configDir
func (s *Service) ReadFile(filename string) (string, error) {
p, err := s.sanitizedPath(filename)
if err != nil {
return "", err
}
b, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("file not found: %s", filename)
}
return "", err
}
return string(b), nil
}
// Preview validates and diffs without writing
type PreviewResult struct {
Valid bool `json:"valid"`
Errors []ValidationError `json:"errors,omitempty"`
Diff string `json:"diff"`
Warnings []string `json:"warnings,omitempty"`
}
func (s *Service) Preview(filename, content string) PreviewResult {
errs := ValidateContent(filename, content)
if len(errs) > 0 {
return PreviewResult{Valid: false, Errors: errs}
}
// Diff vs current file (if exists)
oldContent := ""
if p, err := s.sanitizedPath(filename); err == nil {
if b, err := os.ReadFile(p); err == nil {
oldContent = string(b)
}
}
diff := UnifiedDiff(filename, oldContent, content)
return PreviewResult{Valid: true, Diff: diff}
}
// Apply validates, backs up, then atomically writes. Requires confirm=true caller.
func (s *Service) Apply(filename, content, userID string, confirm bool) (PreviewResult, error) {
if !confirm {
return PreviewResult{}, fmt.Errorf("confirmation required: set confirm:true")
}
errs := ValidateContent(filename, content)
if len(errs) > 0 {
return PreviewResult{Valid: false, Errors: errs}, nil
}
p, err := s.sanitizedPath(filename)
if err != nil {
return PreviewResult{}, err
}
unlock := s.locks.Lock(filename)
defer unlock()
// Backup current content
oldContent := ""
if b, err := os.ReadFile(p); err == nil {
oldContent = string(b)
} else if !os.IsNotExist(err) {
return PreviewResult{}, fmt.Errorf("read current file: %w", err)
}
diff := UnifiedDiff(filename, oldContent, content)
// Store backup in DB
backupID := uuid.New().String()
_, err = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`,
backupID, filename, oldContent, userID, "apply")
if err != nil {
return PreviewResult{}, fmt.Errorf("store backup: %w", err)
}
// Also filesystem backup
backupPath := filepath.Join(s.configDir, "backups", fmt.Sprintf("%s.%d.bak", filename, time.Now().Unix()))
_ = os.WriteFile(backupPath, []byte(oldContent), 0o644)
// Prune old filesystem backups (keep 20)
s.pruneFilesystemBackups(filename)
// Atomic write: temp file in same dir, fsync, rename
tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4))
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return PreviewResult{}, fmt.Errorf("create temp file: %w", err)
}
if _, err := f.WriteString(content); err != nil {
f.Close()
os.Remove(tmpName)
return PreviewResult{}, fmt.Errorf("write temp: %w", err)
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmpName)
return PreviewResult{}, fmt.Errorf("fsync temp: %w", err)
}
f.Close()
if err := os.Rename(tmpName, p); err != nil {
os.Remove(tmpName)
return PreviewResult{}, fmt.Errorf("rename: %w", err)
}
// fsync directory
if d, err := os.Open(s.configDir); err == nil {
_ = d.Sync()
d.Close()
}
// Prune DB backups (keep 50 per file)
s.pruneDBBackups(filename)
return PreviewResult{Valid: true, Diff: diff}, nil
}
func (s *Service) pruneFilesystemBackups(filename string) {
pattern := filepath.Join(s.configDir, "backups", filename+".*.bak")
matches, _ := filepath.Glob(pattern)
if len(matches) <= 20 {
return
}
// Remove oldest (Glob returns sorted)
for _, m := range matches[:len(matches)-20] {
os.Remove(m)
}
}
func (s *Service) pruneDBBackups(filename string) {
// Keep 50 most recent
_, _ = s.db.Exec(`
DELETE FROM backups WHERE id IN (
SELECT id FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT -1 OFFSET 50
)`, filename)
}
// History lists backups for a file (or all if filename empty)
type BackupInfo struct {
ID string `json:"id"`
Filename string `json:"filename"`
CreatedAt string `json:"created_at"`
CreatedBy string `json:"created_by"`
Reason string `json:"reason"`
}
func (s *Service) History(filename string) ([]BackupInfo, error) {
var rows *sql.Rows
var err error
if filename != "" {
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 50`, filename)
} else {
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason FROM backups ORDER BY created_at DESC, rowid DESC LIMIT 100`)
}
if err != nil {
return nil, err
}
defer rows.Close()
var out []BackupInfo
for rows.Next() {
var b BackupInfo
if err := rows.Scan(&b.ID, &b.Filename, &b.CreatedAt, &b.CreatedBy, &b.Reason); err != nil {
return nil, err
}
out = append(out, b)
}
return out, nil
}
// Rollback restores specified backup (or most recent if backupID empty)
func (s *Service) Rollback(filename, backupID, userID string) (PreviewResult, error) {
if err := ValidateFilename(filename); err != nil {
return PreviewResult{}, err
}
p, err := s.sanitizedPath(filename)
if err != nil {
return PreviewResult{}, err
}
unlock := s.locks.Lock(filename)
defer unlock()
var content string
if backupID != "" {
err = s.db.QueryRow(`SELECT content FROM backups WHERE id=? AND filename=?`, backupID, filename).Scan(&content)
if err == sql.ErrNoRows {
return PreviewResult{}, fmt.Errorf("backup not found")
}
if err != nil {
return PreviewResult{}, err
}
} else {
// Most recent
err = s.db.QueryRow(`SELECT content FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, filename).Scan(&content)
if err == sql.ErrNoRows {
return PreviewResult{}, fmt.Errorf("no backup found for %s", filename)
}
if err != nil {
return PreviewResult{}, err
}
}
// Validate rollback content (allow empty = delete file)
if content != "" {
if errs := ValidateContent(filename, content); len(errs) > 0 {
return PreviewResult{}, fmt.Errorf("rollback content invalid: %s", errs[0].Error())
}
}
// Backup current before rollback
curContent := ""
if b, err := os.ReadFile(p); err == nil {
curContent = string(b)
}
rbID := uuid.New().String()
_, _ = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`,
rbID, filename, curContent, userID, "rollback")
if content == "" {
// Original file was new: delete current file
_ = os.Remove(p)
} else {
tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4))
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return PreviewResult{}, err
}
if _, err := f.WriteString(content); err != nil {
f.Close()
os.Remove(tmpName)
return PreviewResult{}, err
}
f.Sync()
f.Close()
if err := os.Rename(tmpName, p); err != nil {
os.Remove(tmpName)
return PreviewResult{}, err
}
if d, err := os.Open(s.configDir); err == nil {
_ = d.Sync()
d.Close()
}
}
diff := UnifiedDiff(filename, curContent, content)
return PreviewResult{Valid: true, Diff: diff}, nil
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}