Add WireGuard TUI implementation
- Add Go TUI with bubbletea for WireGuard management - Implement client CRUD operations with QR code generation - Add configuration and validation modules - Install/update scripts for client setup - Update Makefile to build binaries to bin/ directory - Add .gitignore for Go projects
This commit is contained in:
267
internal/backup/backup.go
Normal file
267
internal/backup/backup.go
Normal file
@@ -0,0 +1,267 @@
|
||||
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
|
||||
}
|
||||
122
internal/backup/restore.go
Normal file
122
internal/backup/restore.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ValidateBackup checks if a backup exists and is valid
|
||||
func ValidateBackup(backupName string) error {
|
||||
backupDir := "/etc/wg-admin/backups"
|
||||
backupPath := filepath.Join(backupDir, backupName)
|
||||
|
||||
// Check if backup directory exists
|
||||
info, err := os.Stat(backupPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("backup '%s' does not exist", backupName)
|
||||
}
|
||||
return fmt.Errorf("failed to access backup '%s': %w", backupName, err)
|
||||
}
|
||||
|
||||
// Check if it's a directory
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("'%s' is not a valid backup directory", backupName)
|
||||
}
|
||||
|
||||
// Check for required files
|
||||
metadataPath := filepath.Join(backupPath, "backup-info.txt")
|
||||
if _, err := os.Stat(metadataPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("backup '%s' is missing required metadata", backupName)
|
||||
}
|
||||
|
||||
// Check for wireguard directory
|
||||
wgBackupPath := filepath.Join(backupPath, "wireguard")
|
||||
if _, err := os.Stat(wgBackupPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("backup '%s' is missing wireguard configuration", backupName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadWireGuard reloads the WireGuard interface to apply configuration changes
|
||||
func ReloadWireGuard() error {
|
||||
interfaceName := "wg0"
|
||||
|
||||
// Try to down the interface first
|
||||
cmdDown := exec.Command("wg-quick", "down", interfaceName)
|
||||
_ = cmdDown.Run() // Ignore errors if interface is not up
|
||||
|
||||
// Bring the interface up
|
||||
cmdUp := exec.Command("wg-quick", "up", interfaceName)
|
||||
output, err := cmdUp.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reload wireguard interface: %w, output: %s", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBackupSize calculates the total size of a backup directory
|
||||
func GetBackupSize(backupName string) (int64, error) {
|
||||
backupDir := "/etc/wg-admin/backups"
|
||||
backupPath := filepath.Join(backupDir, backupName)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// GetBackupPath returns the full path for a backup name
|
||||
func GetBackupPath(backupName string) string {
|
||||
return filepath.Join("/etc/wg-admin/backups", backupName)
|
||||
}
|
||||
|
||||
// ParseBackupName extracts operation and timestamp from backup directory name
|
||||
// Format: wg-backup-{operation}-{timestamp}
|
||||
func ParseBackupName(backupName string) (operation, timestamp string, err error) {
|
||||
if !strings.HasPrefix(backupName, "wg-backup-") {
|
||||
return "", "", fmt.Errorf("invalid backup name format")
|
||||
}
|
||||
|
||||
nameWithoutPrefix := strings.TrimPrefix(backupName, "wg-backup-")
|
||||
|
||||
// Timestamp format: 20060102-150405 (15 chars)
|
||||
if len(nameWithoutPrefix) < 16 {
|
||||
return "", "", fmt.Errorf("backup name too short")
|
||||
}
|
||||
|
||||
// Extract operation (everything before last timestamp)
|
||||
timestampLen := 15
|
||||
if len(nameWithoutPrefix) > timestampLen+1 {
|
||||
operation = nameWithoutPrefix[:len(nameWithoutPrefix)-timestampLen-1]
|
||||
// Remove trailing dash if present
|
||||
if strings.HasSuffix(operation, "-") {
|
||||
operation = operation[:len(operation)-1]
|
||||
}
|
||||
}
|
||||
|
||||
// Extract timestamp
|
||||
timestamp = nameWithoutPrefix[len(nameWithoutPrefix)-timestampLen:]
|
||||
|
||||
// Validate timestamp format
|
||||
if _, err := time.Parse("20060102-150405", timestamp); err != nil {
|
||||
return "", "", fmt.Errorf("invalid timestamp format in backup name: %w", err)
|
||||
}
|
||||
|
||||
return operation, timestamp, nil
|
||||
}
|
||||
Reference in New Issue
Block a user