928 lines
27 KiB
Go
928 lines
27 KiB
Go
package file
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
|
|
)
|
|
|
|
type Service struct {
|
|
configDir string
|
|
db *sql.DB
|
|
auditRepo *repositories.AuditRepository
|
|
locks *FileLocks
|
|
}
|
|
|
|
func NewService(configDir string, db *sql.DB, auditRepo *repositories.AuditRepository) (*Service, error) {
|
|
abs, err := filepath.Abs(configDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve config dir: %w", err)
|
|
}
|
|
if err := os.MkdirAll(abs, 0o700); err != nil {
|
|
return nil, fmt.Errorf("create config dir: %w", err)
|
|
}
|
|
// Ensure backups dir exists
|
|
if err := os.MkdirAll(filepath.Join(abs, "backups"), 0o700); err != nil {
|
|
return nil, fmt.Errorf("create backups dir: %w", err)
|
|
}
|
|
return &Service{configDir: abs, db: db, auditRepo: auditRepo, 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 {
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: "",
|
|
Username: "",
|
|
Role: "",
|
|
Action: "failed_apply", // Preview failure is logged as failed_apply
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "failed_validation",
|
|
ErrorCategory: "validation",
|
|
RollbackOccurred: false,
|
|
ErrorMessage: errs[0].Error(),
|
|
})
|
|
return PreviewResult{Valid: false, Errors: errs}
|
|
}
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: "",
|
|
Username: "",
|
|
Role: "",
|
|
Action: "preview",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: contentHash(content),
|
|
Result: "success",
|
|
ErrorCategory: "",
|
|
RollbackOccurred: false,
|
|
})
|
|
// 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.
|
|
// After writing, verifies the file content matches. If verification fails,
|
|
// attempts rollback to the previous known-good version.
|
|
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 {
|
|
// Log failed validation audit event
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "failed_apply",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "failed_validation",
|
|
ErrorCategory: "validation",
|
|
RollbackOccurred: false,
|
|
ErrorMessage: errs[0].Error(),
|
|
})
|
|
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()
|
|
|
|
// Audit: apply started
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "apply",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "",
|
|
ErrorCategory: "",
|
|
RollbackOccurred: false,
|
|
})
|
|
|
|
// 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()
|
|
contentHash := contentHash(oldContent)
|
|
_, err = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason, hash) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
backupID, filename, oldContent, userID, "apply", contentHash)
|
|
if err != nil {
|
|
// Log backup storage failure but continue if possible
|
|
_ = err
|
|
}
|
|
// Also filesystem backup
|
|
backupPath := filepath.Join(s.configDir, "backups", fmt.Sprintf("%s.%d.bak", filename, time.Now().Unix()))
|
|
_ = os.WriteFile(backupPath, []byte(oldContent), 0o600)
|
|
// 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, 0o600)
|
|
if err != nil {
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "apply",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "write_failed",
|
|
ErrorCategory: "write",
|
|
RollbackOccurred: false,
|
|
ErrorMessage: fmt.Sprintf("create temp file: %v", err),
|
|
})
|
|
return PreviewResult{}, fmt.Errorf("create temp file: %w", err)
|
|
}
|
|
if _, err := f.WriteString(content); err != nil {
|
|
f.Close()
|
|
os.Remove(tmpName)
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "apply",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "write_failed",
|
|
ErrorCategory: "write",
|
|
RollbackOccurred: false,
|
|
ErrorMessage: fmt.Sprintf("write temp: %v", err),
|
|
})
|
|
return PreviewResult{}, fmt.Errorf("write temp: %w", err)
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
f.Close()
|
|
os.Remove(tmpName)
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "apply",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "write_failed",
|
|
ErrorCategory: "write",
|
|
RollbackOccurred: false,
|
|
ErrorMessage: fmt.Sprintf("fsync temp: %v", err),
|
|
})
|
|
return PreviewResult{}, fmt.Errorf("fsync temp: %w", err)
|
|
}
|
|
f.Close()
|
|
if err := os.Rename(tmpName, p); err != nil {
|
|
os.Remove(tmpName)
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "apply",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "write_failed",
|
|
ErrorCategory: "write",
|
|
RollbackOccurred: false,
|
|
ErrorMessage: fmt.Sprintf("rename: %v", err),
|
|
})
|
|
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)
|
|
|
|
// --- POST-WRITE VERIFICATION ---
|
|
// Read the file back and verify content matches what was written.
|
|
// This is the safest available verification mechanism when Traefik hot-reload
|
|
// cannot be directly triggered or observed from the GUI.
|
|
var newContent []byte
|
|
newContent, err = os.ReadFile(p)
|
|
if err != nil {
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "apply",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "apply_failed",
|
|
ErrorCategory: "traefik_rejection",
|
|
RollbackOccurred: false,
|
|
ErrorMessage: fmt.Sprintf("read back written file: %v", err),
|
|
})
|
|
return PreviewResult{}, fmt.Errorf("read back written file: %w", err)
|
|
}
|
|
if string(newContent) != content {
|
|
// Content mismatch — attempt rollback to the previous known-good version.
|
|
// Note: Traefik hot-reload verification is not instrumented from the GUI;
|
|
// the file system state is what we can verify.
|
|
rollbackResult, rollbackErr := s.Rollback(filename, backupID, userID)
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "apply",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "apply_failed_rollback_succeeded",
|
|
ErrorCategory: "verification",
|
|
RollbackOccurred: true,
|
|
ErrorMessage: fmt.Sprintf("verification failed; rollback: %v", rollbackErr),
|
|
})
|
|
if rollbackErr == nil && rollbackResult.Valid {
|
|
// Rollback succeeded — apply effectively failed even though the file
|
|
// write temporarily succeeded. Return a clear error indicating rollback.
|
|
return PreviewResult{}, fmt.Errorf("apply: file written but verification failed; automatically rolled back")
|
|
}
|
|
// Rollback also failed — return both the original error and the rollback status.
|
|
return PreviewResult{}, fmt.Errorf("apply: file written but verification failed; could not roll back: %w", rollbackErr)
|
|
}
|
|
// Verification passed — content matches what was written.
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "apply",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: contentHash(content),
|
|
Result: "success",
|
|
ErrorCategory: "",
|
|
RollbackOccurred: false,
|
|
})
|
|
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"`
|
|
Hash string `json:"hash,omitempty"` // SHA256 of content for integrity verification
|
|
}
|
|
|
|
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, hash 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, hash 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, &b.Hash); 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 {
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "rollback",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "failed",
|
|
ErrorCategory: "validation",
|
|
RollbackOccurred: false,
|
|
ErrorMessage: err.Error(),
|
|
})
|
|
return PreviewResult{}, err
|
|
}
|
|
p, err := s.sanitizedPath(filename)
|
|
if err != nil {
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "rollback",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "failed",
|
|
ErrorCategory: "validation",
|
|
RollbackOccurred: false,
|
|
ErrorMessage: err.Error(),
|
|
})
|
|
return PreviewResult{}, err
|
|
}
|
|
unlock := s.locks.Lock(filename)
|
|
defer unlock()
|
|
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "rollback",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "",
|
|
ErrorCategory: "",
|
|
})
|
|
|
|
var content string
|
|
var storedHash string
|
|
if backupID != "" {
|
|
err = s.db.QueryRow(`SELECT content, hash FROM backups WHERE id=? AND filename=?`, backupID, filename).Scan(&content, &storedHash)
|
|
if err == sql.ErrNoRows {
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "rollback",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "backup_not_found",
|
|
ErrorCategory: "not_found",
|
|
RollbackOccurred: false,
|
|
ErrorMessage: "backup not found",
|
|
})
|
|
return PreviewResult{}, fmt.Errorf("backup not found")
|
|
}
|
|
if err != nil {
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "rollback",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "failed",
|
|
ErrorCategory: "database",
|
|
RollbackOccurred: false,
|
|
ErrorMessage: fmt.Sprintf("read backup: %v", err),
|
|
})
|
|
return PreviewResult{}, err
|
|
}
|
|
// Verify hash integrity
|
|
computedHash := contentHash(content)
|
|
if computedHash != storedHash {
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "rollback",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "failed",
|
|
ErrorCategory: "validation",
|
|
RollbackOccurred: false,
|
|
ErrorMessage: "backup integrity check failed - hash mismatch",
|
|
})
|
|
return PreviewResult{}, fmt.Errorf("backup integrity check failed - hash mismatch")
|
|
}
|
|
} else {
|
|
// Most recent
|
|
err = s.db.QueryRow(`SELECT content, hash FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, filename).Scan(&content, &storedHash)
|
|
if err == sql.ErrNoRows {
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "rollback",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "backup_not_found",
|
|
ErrorCategory: "not_found",
|
|
RollbackOccurred: false,
|
|
ErrorMessage: "no backup found for " + filename,
|
|
})
|
|
return PreviewResult{}, fmt.Errorf("no backup found for %s", filename)
|
|
}
|
|
if err != nil {
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "rollback",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "failed",
|
|
ErrorCategory: "database",
|
|
RollbackOccurred: false,
|
|
ErrorMessage: fmt.Sprintf("read backup: %v", err),
|
|
})
|
|
return PreviewResult{}, err
|
|
}
|
|
// Verify hash integrity
|
|
computedHash := contentHash(content)
|
|
if computedHash != storedHash {
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "rollback",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "failed",
|
|
ErrorCategory: "validation",
|
|
RollbackOccurred: false,
|
|
ErrorMessage: "backup integrity check failed - hash mismatch",
|
|
})
|
|
return PreviewResult{}, fmt.Errorf("backup integrity check failed - hash mismatch")
|
|
}
|
|
}
|
|
|
|
// Validate rollback content (allow empty = delete file)
|
|
if content != "" {
|
|
if errs := ValidateContent(filename, content); len(errs) > 0 {
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "rollback",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "failed",
|
|
ErrorCategory: "validation",
|
|
RollbackOccurred: false,
|
|
ErrorMessage: errs[0].Error(),
|
|
})
|
|
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()
|
|
contentHash := contentHash(curContent)
|
|
_, _ = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason, hash) VALUES (?, ?, ?, ?, ?)`,
|
|
rbID, filename, curContent, userID, "rollback", contentHash)
|
|
|
|
if content == "" {
|
|
// Original file was new: delete current file
|
|
_ = os.Remove(p)
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "rollback",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "success",
|
|
ErrorCategory: "",
|
|
RollbackOccurred: true,
|
|
})
|
|
} else {
|
|
tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4))
|
|
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
|
if err != nil {
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "rollback",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "failed",
|
|
ErrorCategory: "write",
|
|
RollbackOccurred: true,
|
|
ErrorMessage: fmt.Sprintf("create temp file: %v", err),
|
|
})
|
|
return PreviewResult{}, err
|
|
}
|
|
if _, err := f.WriteString(content); err != nil {
|
|
f.Close()
|
|
os.Remove(tmpName)
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "rollback",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "failed",
|
|
ErrorCategory: "write",
|
|
RollbackOccurred: true,
|
|
ErrorMessage: fmt.Sprintf("write temp: %v", err),
|
|
})
|
|
return PreviewResult{}, err
|
|
}
|
|
f.Sync()
|
|
f.Close()
|
|
if err := os.Rename(tmpName, p); err != nil {
|
|
os.Remove(tmpName)
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "rollback",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: "",
|
|
Result: "failed",
|
|
ErrorCategory: "write",
|
|
RollbackOccurred: true,
|
|
ErrorMessage: fmt.Sprintf("rename: %v", err),
|
|
})
|
|
return PreviewResult{}, err
|
|
}
|
|
if d, err := os.Open(s.configDir); err == nil {
|
|
_ = d.Sync()
|
|
d.Close()
|
|
}
|
|
s.logAuditEvent(&repositories.AuditLogEntry{
|
|
ID: "",
|
|
UserID: userID,
|
|
Username: "",
|
|
Role: "",
|
|
Action: "rollback",
|
|
ResourceType: "file",
|
|
ResourceName: filename,
|
|
Provider: "",
|
|
SourceFile: filename,
|
|
ContentHash: contentHash(content),
|
|
Result: "success",
|
|
ErrorCategory: "",
|
|
RollbackOccurred: true,
|
|
})
|
|
}
|
|
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)
|
|
}
|
|
|
|
func (s *Service) logAuditEvent(entry *repositories.AuditLogEntry) {
|
|
if s.auditRepo == nil {
|
|
return
|
|
}
|
|
var safeMsg string
|
|
if entry.ErrorMessage != "" {
|
|
safeMsg = bestEffortSanitize(entry.ErrorMessage)
|
|
}
|
|
entry.ErrorMessage = safeMsg
|
|
entry.Timestamp = time.Now().UTC()
|
|
if entry.ID == "" {
|
|
entry.ID = uuid.New().String()
|
|
}
|
|
if err := s.auditRepo.Create(entry); err != nil {
|
|
_ = err
|
|
return
|
|
}
|
|
}
|
|
|
|
func bestEffortSanitize(msg string) string {
|
|
result := msg
|
|
// Best-effort redaction of common secret patterns
|
|
result = redactPasswords(result)
|
|
result = redactTokens(result)
|
|
return result
|
|
}
|
|
|
|
func redactPasswords(msg string) string {
|
|
// Redact common secret patterns before logging to audit
|
|
patterns := [][]string{
|
|
{"password=", "password=REDACTED"},
|
|
{"password:\"", "password:\"REDACTED\""},
|
|
{"password:'", "password:'REDACTED'"},
|
|
{"secret=", "secret=REDACTED"},
|
|
{"apiKey=", "apiKey=REDACTED"},
|
|
{"token=", "token=REDACTED"},
|
|
}
|
|
for _, p := range patterns {
|
|
msg = strings.ReplaceAll(msg, p[0], p[1])
|
|
}
|
|
return msg
|
|
}
|
|
|
|
func redactTokens(msg string) string {
|
|
// Redact Bearer tokens and authorization headers
|
|
msg = strings.ReplaceAll(msg, "Bearer ", "Bearer REDACTED")
|
|
msg = strings.ReplaceAll(msg, "Authorization: ", "Authorization: REDACTED")
|
|
// Redact long hex strings (32+ chars) that look like session/token IDs
|
|
re := regexp.MustCompile(`[0-9a-fA-F]{32,}`)
|
|
msg = re.ReplaceAllString(msg, "REDACTED_HEX")
|
|
return msg
|
|
}
|
|
|
|
// contentHash returns a short hash of the configuration content for audit logging.
|
|
func contentHash(content string) string {
|
|
h := sha256.Sum256([]byte(content))
|
|
return hex.EncodeToString(h[:8])
|
|
}
|
|
|
|
// FileRevision returns the current revision token for a file.
|
|
// The token is a content hash that can be used for optimistic concurrency control.
|
|
// Clients must include this token in preview/apply requests; if the file has changed
|
|
// since the token was generated, the request is rejected with HTTP 409.
|
|
func (s *Service) FileRevision(filename string) (string, error) {
|
|
hash, err := s.fileContentHash(filename)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return hash, nil
|
|
}
|
|
|
|
// fileContentHash computes the SHA256 hash of a file's content for revision tracking.
|
|
func (s *Service) fileContentHash(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 contentHash(string(b)), nil
|
|
}
|