package backup import ( "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "time" ) // Backup represents a backup with metadata type Backup struct { Name string // Backup name (directory name) Path string // Full path to backup directory Operation string // Operation that triggered the backup Timestamp time.Time // When the backup was created Size int64 // Size in bytes } // CreateBackup creates a new backup with the specified operation func CreateBackup(operation string) error { backupDir := "/etc/wg-admin/backups" // Create backup directory if it doesn't exist if err := os.MkdirAll(backupDir, 0700); err != nil { return fmt.Errorf("failed to create backup directory: %w", err) } // Create timestamped backup directory timestamp := time.Now().Format("20060102-150405") backupName := fmt.Sprintf("wg-backup-%s-%s", operation, timestamp) backupPath := filepath.Join(backupDir, backupName) if err := os.MkdirAll(backupPath, 0700); err != nil { return fmt.Errorf("failed to create backup path: %w", err) } // Backup entire wireguard directory to maintain structure expected by restore.go wgConfigPath := "/etc/wireguard" if _, err := os.Stat(wgConfigPath); err == nil { backupWgPath := filepath.Join(backupPath, "wireguard") if err := exec.Command("cp", "-a", wgConfigPath, backupWgPath).Run(); err != nil { return fmt.Errorf("failed to backup wireguard config: %w", err) } } // Create backup metadata metadataPath := filepath.Join(backupPath, "backup-info.txt") metadata := fmt.Sprintf("Backup created: %s\nOperation: %s\nTimestamp: %s\n", time.Now().Format(time.RFC3339), operation, timestamp) if err := os.WriteFile(metadataPath, []byte(metadata), 0600); err != nil { return fmt.Errorf("failed to create backup metadata: %w", err) } // Set restrictive permissions on backup directory if err := os.Chmod(backupPath, 0700); err != nil { return fmt.Errorf("failed to set backup directory permissions: %w", err) } // Apply retention policy (keep last 10 backups) if err := applyRetentionPolicy(backupDir, 10); err != nil { // Log but don't fail on retention errors fmt.Fprintf(os.Stderr, "Warning: failed to apply retention policy: %v\n", err) } return nil } // ListBackups returns all available backups sorted by creation time (newest first) func ListBackups() ([]Backup, error) { backupDir := "/etc/wg-admin/backups" // Check if backup directory exists if _, err := os.Stat(backupDir); os.IsNotExist(err) { return []Backup{}, nil } // Read all entries entries, err := os.ReadDir(backupDir) if err != nil { return nil, fmt.Errorf("failed to read backup directory: %w", err) } var backups []Backup // Parse backup directories for _, entry := range entries { if !entry.IsDir() { continue } name := entry.Name() // Check if it's a valid backup directory (starts with "wg-backup-") if !strings.HasPrefix(name, "wg-backup-") { continue } // Parse backup name to extract operation and timestamp // Format: wg-backup-{operation}-{timestamp} parts := strings.SplitN(name, "-", 4) if len(parts) < 4 { continue } operation := parts[2] timestampStr := parts[3] // Parse timestamp timestamp, err := time.Parse("20060102-150405", timestampStr) if err != nil { // If timestamp parsing fails, use directory modification time info, err := entry.Info() if err != nil { continue } timestamp = info.ModTime() } // Get backup size backupPath := filepath.Join(backupDir, name) size, err := getBackupSize(backupPath) if err != nil { size = 0 } backup := Backup{ Name: name, Path: backupPath, Operation: operation, Timestamp: timestamp, Size: size, } backups = append(backups, backup) } // Sort by timestamp (newest first) sort.Slice(backups, func(i, j int) bool { return backups[i].Timestamp.After(backups[j].Timestamp) }) return backups, nil } // getBackupSize calculates the total size of a backup directory func getBackupSize(backupPath string) (int64, error) { var size int64 err := filepath.Walk(backupPath, func(_ string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() { size += info.Size() } return nil }) return size, err } // RestoreBackup restores WireGuard configurations from a backup by name func RestoreBackup(backupName string) error { backupDir := "/etc/wg-admin/backups" backupPath := filepath.Join(backupDir, backupName) // Verify backup exists if _, err := os.Stat(backupPath); os.IsNotExist(err) { return fmt.Errorf("backup does not exist: %s", backupName) } // Check for wireguard subdirectory wgSourcePath := filepath.Join(backupPath, "wireguard") if _, err := os.Stat(wgSourcePath); os.IsNotExist(err) { return fmt.Errorf("backup does not contain wireguard configuration") } // Restore entire wireguard directory wgDestPath := "/etc/wireguard" if err := os.RemoveAll(wgDestPath); err != nil { return fmt.Errorf("failed to remove existing wireguard config: %w", err) } if err := exec.Command("cp", "-a", wgSourcePath, wgDestPath).Run(); err != nil { return fmt.Errorf("failed to restore wireguard config: %w", err) } // Set proper permissions on restored files if err := setRestoredPermissions(wgDestPath); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to set permissions on restored files: %v\n", err) } return nil } // setRestoredPermissions sets appropriate permissions on restored WireGuard files func setRestoredPermissions(wgPath string) error { // Set 0600 on .conf files return filepath.Walk(wgPath, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() && strings.HasSuffix(path, ".conf") { if err := os.Chmod(path, 0600); err != nil { return fmt.Errorf("failed to chmod %s: %w", path, err) } } return nil }) } // applyRetentionPolicy keeps only the last N backups func applyRetentionPolicy(backupDir string, keepCount int) error { // List all backup directories entries, err := os.ReadDir(backupDir) if err != nil { return err } // Filter backup directories and sort by modification time var backups []os.FileInfo for _, entry := range entries { if entry.IsDir() && len(entry.Name()) > 10 && entry.Name()[:10] == "wg-backup-" { info, err := entry.Info() if err != nil { continue } backups = append(backups, info) } } // If we have more backups than we want to keep, remove the oldest if len(backups) > keepCount { // Sort by modification time (oldest first) for i := 0; i < len(backups); i++ { for j := i + 1; j < len(backups); j++ { if backups[i].ModTime().After(backups[j].ModTime()) { backups[i], backups[j] = backups[j], backups[i] } } } // Remove oldest backups toRemove := len(backups) - keepCount for i := 0; i < toRemove; i++ { backupPath := filepath.Join(backupDir, backups[i].Name()) if err := os.RemoveAll(backupPath); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to remove old backup %s: %v\n", backupPath, err) } } } return nil } // BackupConfig is a compatibility wrapper that calls CreateBackup func BackupConfig(operation string) (string, error) { if err := CreateBackup(operation); err != nil { return "", err } // Return the backup path for compatibility timestamp := time.Now().Format("20060102-150405") backupName := fmt.Sprintf("wg-backup-%s-%s", operation, timestamp) return filepath.Join("/etc/wg-admin/backups", backupName), nil }