backup: pre-hardening baseline
This commit is contained in:
commit
9e4c612dcb
57 changed files with 10393 additions and 0 deletions
385
backend/internal/config/file/service.go
Normal file
385
backend/internal/config/file/service.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue