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:
Calmcacil
2026-01-12 19:03:35 +01:00
parent 5ac68db854
commit 26120b8bc2
37 changed files with 6330 additions and 97 deletions

267
internal/backup/backup.go Normal file
View 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
View 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
}