|
|
@@ -0,0 +1,678 @@
|
|
|
+package backup
|
|
|
+
|
|
|
+import (
|
|
|
+ "archive/zip"
|
|
|
+ "bytes"
|
|
|
+ "crypto/sha256"
|
|
|
+ "encoding/hex"
|
|
|
+ "encoding/json"
|
|
|
+ "errors"
|
|
|
+ "fmt"
|
|
|
+ "io"
|
|
|
+ "log"
|
|
|
+ "os"
|
|
|
+ "os/exec"
|
|
|
+ "path/filepath"
|
|
|
+ "sort"
|
|
|
+ "strings"
|
|
|
+ "sync"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ release "debtik"
|
|
|
+)
|
|
|
+
|
|
|
+const (
|
|
|
+ DefaultDebtikDir = "/etc/debtik"
|
|
|
+ DefaultNetworkDir = "/etc/network/interfaces.d"
|
|
|
+ DefaultBackupStore = "/var/lib/debtik/backups"
|
|
|
+
|
|
|
+ MaxArchiveEntries = 5000
|
|
|
+ MaxArchiveBytes = 100 * 1024 * 1024 // 100 MB max uncompressed
|
|
|
+ BackupFormatVer = "1.0"
|
|
|
+)
|
|
|
+
|
|
|
+// Manager handles backup generation, inspection, restoration, and local retention.
|
|
|
+type Manager struct {
|
|
|
+ debtikDir string
|
|
|
+ networkDir string
|
|
|
+ backupStore string
|
|
|
+ restartFunc func() error
|
|
|
+ mu sync.Mutex
|
|
|
+}
|
|
|
+
|
|
|
+// NewManager creates a new backup Manager with the provided or default directories.
|
|
|
+func NewManager(debtikDir, networkDir, backupStore string, restartFunc func() error) *Manager {
|
|
|
+ if debtikDir == "" {
|
|
|
+ debtikDir = DefaultDebtikDir
|
|
|
+ }
|
|
|
+ if networkDir == "" {
|
|
|
+ networkDir = DefaultNetworkDir
|
|
|
+ }
|
|
|
+ if backupStore == "" {
|
|
|
+ backupStore = DefaultBackupStore
|
|
|
+ }
|
|
|
+ return &Manager{
|
|
|
+ debtikDir: filepath.Clean(debtikDir),
|
|
|
+ networkDir: filepath.Clean(networkDir),
|
|
|
+ backupStore: filepath.Clean(backupStore),
|
|
|
+ restartFunc: restartFunc,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// CreateBackup generates an in-memory zip archive containing all DebTik configurations.
|
|
|
+func (m *Manager) CreateBackup(comment string) ([]byte, *Manifest, error) {
|
|
|
+ m.mu.Lock()
|
|
|
+ defer m.mu.Unlock()
|
|
|
+ return m.createBackupLocked(comment)
|
|
|
+}
|
|
|
+
|
|
|
+func (m *Manager) createBackupLocked(comment string) ([]byte, *Manifest, error) {
|
|
|
+ hostname, _ := os.Hostname()
|
|
|
+ if hostname == "" {
|
|
|
+ hostname = "DebTik"
|
|
|
+ }
|
|
|
+
|
|
|
+ manifest := &Manifest{
|
|
|
+ Version: BackupFormatVer,
|
|
|
+ DebTikVersion: release.Version(),
|
|
|
+ Hostname: hostname,
|
|
|
+ CreatedAt: time.Now().UTC(),
|
|
|
+ Comment: strings.TrimSpace(comment),
|
|
|
+ Files: make([]FileInfo, 0),
|
|
|
+ }
|
|
|
+
|
|
|
+ buf := new(bytes.Buffer)
|
|
|
+ zw := zip.NewWriter(buf)
|
|
|
+
|
|
|
+ // 1. Collect /etc/debtik configuration tree
|
|
|
+ if err := m.archiveDebtikTree(zw, manifest); err != nil {
|
|
|
+ _ = zw.Close()
|
|
|
+ return nil, nil, fmt.Errorf("failed archiving debtik config tree: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. Collect /etc/network/interfaces.d configuration files
|
|
|
+ if err := m.archiveNetworkFiles(zw, manifest); err != nil {
|
|
|
+ _ = zw.Close()
|
|
|
+ return nil, nil, fmt.Errorf("failed archiving network config files: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. Write manifest.json
|
|
|
+ manifestBytes, err := json.MarshalIndent(manifest, "", " ")
|
|
|
+ if err != nil {
|
|
|
+ _ = zw.Close()
|
|
|
+ return nil, nil, fmt.Errorf("failed serializing manifest: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ manifestHeader := &zip.FileHeader{
|
|
|
+ Name: "manifest.json",
|
|
|
+ Method: zip.Deflate,
|
|
|
+ Modified: manifest.CreatedAt,
|
|
|
+ }
|
|
|
+ manifestHeader.SetMode(0644)
|
|
|
+ w, err := zw.CreateHeader(manifestHeader)
|
|
|
+ if err != nil {
|
|
|
+ _ = zw.Close()
|
|
|
+ return nil, nil, fmt.Errorf("failed creating manifest in zip: %w", err)
|
|
|
+ }
|
|
|
+ if _, err := w.Write(manifestBytes); err != nil {
|
|
|
+ _ = zw.Close()
|
|
|
+ return nil, nil, fmt.Errorf("failed writing manifest to zip: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := zw.Close(); err != nil {
|
|
|
+ return nil, nil, fmt.Errorf("failed finalizing zip archive: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ return buf.Bytes(), manifest, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (m *Manager) archiveDebtikTree(zw *zip.Writer, manifest *Manifest) error {
|
|
|
+ if _, err := os.Stat(m.debtikDir); os.IsNotExist(err) {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ return filepath.Walk(m.debtikDir, func(path string, info os.FileInfo, err error) error {
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if info.IsDir() {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ // Skip sockets, named pipes, etc.
|
|
|
+ if !info.Mode().IsRegular() {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ rel, err := filepath.Rel(m.debtikDir, path)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ rel = filepath.ToSlash(rel)
|
|
|
+ zipPath := "debtik/" + rel
|
|
|
+
|
|
|
+ data, err := os.ReadFile(path)
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("cannot read file %s: %w", path, err)
|
|
|
+ }
|
|
|
+
|
|
|
+ hash := sha256.Sum256(data)
|
|
|
+ hashStr := hex.EncodeToString(hash[:])
|
|
|
+
|
|
|
+ hdr := &zip.FileHeader{
|
|
|
+ Name: zipPath,
|
|
|
+ Method: zip.Deflate,
|
|
|
+ Modified: info.ModTime().UTC(),
|
|
|
+ }
|
|
|
+ hdr.SetMode(info.Mode().Perm())
|
|
|
+
|
|
|
+ w, err := zw.CreateHeader(hdr)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if _, err := w.Write(data); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+
|
|
|
+ manifest.Files = append(manifest.Files, FileInfo{
|
|
|
+ Path: zipPath,
|
|
|
+ Size: int64(len(data)),
|
|
|
+ Mode: uint32(info.Mode().Perm()),
|
|
|
+ SHA256: hashStr,
|
|
|
+ })
|
|
|
+ return nil
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+func (m *Manager) archiveNetworkFiles(zw *zip.Writer, manifest *Manifest) error {
|
|
|
+ if _, err := os.Stat(m.networkDir); os.IsNotExist(err) {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ entries, err := os.ReadDir(m.networkDir)
|
|
|
+ if err != nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, entry := range entries {
|
|
|
+ if entry.IsDir() {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ name := entry.Name()
|
|
|
+ // Only archive .cfg files (e.g. interfaces.cfg, vlans.cfg)
|
|
|
+ if !strings.HasSuffix(name, ".cfg") {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ filePath := filepath.Join(m.networkDir, name)
|
|
|
+ info, err := entry.Info()
|
|
|
+ if err != nil || !info.Mode().IsRegular() {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ data, err := os.ReadFile(filePath)
|
|
|
+ if err != nil {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ hash := sha256.Sum256(data)
|
|
|
+ hashStr := hex.EncodeToString(hash[:])
|
|
|
+
|
|
|
+ zipPath := "network/" + name
|
|
|
+ hdr := &zip.FileHeader{
|
|
|
+ Name: zipPath,
|
|
|
+ Method: zip.Deflate,
|
|
|
+ Modified: info.ModTime().UTC(),
|
|
|
+ }
|
|
|
+ hdr.SetMode(info.Mode().Perm())
|
|
|
+
|
|
|
+ w, err := zw.CreateHeader(hdr)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if _, err := w.Write(data); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+
|
|
|
+ manifest.Files = append(manifest.Files, FileInfo{
|
|
|
+ Path: zipPath,
|
|
|
+ Size: int64(len(data)),
|
|
|
+ Mode: uint32(info.Mode().Perm()),
|
|
|
+ SHA256: hashStr,
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// SaveBackup creates a backup and saves it to the local backupStore directory.
|
|
|
+func (m *Manager) SaveBackup(name, comment string) (string, *Manifest, error) {
|
|
|
+ m.mu.Lock()
|
|
|
+ defer m.mu.Unlock()
|
|
|
+ return m.saveBackupLocked(name, comment)
|
|
|
+}
|
|
|
+
|
|
|
+func (m *Manager) saveBackupLocked(name, comment string) (string, *Manifest, error) {
|
|
|
+ if err := os.MkdirAll(m.backupStore, 0700); err != nil {
|
|
|
+ return "", nil, fmt.Errorf("failed creating backup storage directory: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ data, manifest, err := m.createBackupLocked(comment)
|
|
|
+ if err != nil {
|
|
|
+ return "", nil, err
|
|
|
+ }
|
|
|
+
|
|
|
+ if name == "" {
|
|
|
+ name = fmt.Sprintf("debtik-backup-%s-%s.zip", sanitizeFilename(manifest.Hostname), manifest.CreatedAt.Format("20060102-150405"))
|
|
|
+ }
|
|
|
+ if !strings.HasSuffix(name, ".zip") {
|
|
|
+ name += ".zip"
|
|
|
+ }
|
|
|
+ name = sanitizeFilename(name)
|
|
|
+ targetPath := filepath.Join(m.backupStore, name)
|
|
|
+
|
|
|
+ if err := os.WriteFile(targetPath, data, 0600); err != nil {
|
|
|
+ return "", nil, fmt.Errorf("failed writing backup file: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ return targetPath, manifest, nil
|
|
|
+}
|
|
|
+
|
|
|
+// CreatePreRestoreBackup creates a safety backup before applying any restored configurations.
|
|
|
+// It keeps the latest 5 pre-restore backups, pruning older ones.
|
|
|
+func (m *Manager) CreatePreRestoreBackup() (string, error) {
|
|
|
+ m.mu.Lock()
|
|
|
+ defer m.mu.Unlock()
|
|
|
+ return m.createPreRestoreBackupLocked()
|
|
|
+}
|
|
|
+
|
|
|
+func (m *Manager) createPreRestoreBackupLocked() (string, error) {
|
|
|
+ if err := os.MkdirAll(m.backupStore, 0700); err != nil {
|
|
|
+ return "", err
|
|
|
+ }
|
|
|
+
|
|
|
+ hostname, _ := os.Hostname()
|
|
|
+ if hostname == "" {
|
|
|
+ hostname = "DebTik"
|
|
|
+ }
|
|
|
+ fileName := fmt.Sprintf("pre-restore-%s-%s-%d.zip", sanitizeFilename(hostname), time.Now().UTC().Format("20060102-150405"), time.Now().UnixNano()%1000000)
|
|
|
+ savedPath, _, err := m.saveBackupLocked(fileName, "Automatic pre-restore safety fallback")
|
|
|
+ if err != nil {
|
|
|
+ return "", err
|
|
|
+ }
|
|
|
+
|
|
|
+ // Prune older pre-restore backups, keeping latest 5
|
|
|
+ m.prunePreRestoreBackups(5)
|
|
|
+
|
|
|
+ return savedPath, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (m *Manager) prunePreRestoreBackups(keep int) {
|
|
|
+ entries, err := os.ReadDir(m.backupStore)
|
|
|
+ if err != nil {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ type fileItem struct {
|
|
|
+ name string
|
|
|
+ modTime time.Time
|
|
|
+ }
|
|
|
+ var preRestores []fileItem
|
|
|
+
|
|
|
+ for _, e := range entries {
|
|
|
+ if e.IsDir() || !strings.HasPrefix(e.Name(), "pre-restore-") || !strings.HasSuffix(e.Name(), ".zip") {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ info, err := e.Info()
|
|
|
+ if err == nil {
|
|
|
+ preRestores = append(preRestores, fileItem{name: e.Name(), modTime: info.ModTime()})
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if len(preRestores) <= keep {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ sort.Slice(preRestores, func(i, j int) bool {
|
|
|
+ return preRestores[i].modTime.After(preRestores[j].modTime)
|
|
|
+ })
|
|
|
+
|
|
|
+ for i := keep; i < len(preRestores); i++ {
|
|
|
+ _ = os.Remove(filepath.Join(m.backupStore, preRestores[i].name))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// InspectBackup verifies the integrity of an archive and extracts its manifest without restoring.
|
|
|
+func (m *Manager) InspectBackup(r io.ReaderAt, size int64) (*Manifest, error) {
|
|
|
+ zr, err := zip.NewReader(r, size)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("invalid zip archive: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ if len(zr.File) > MaxArchiveEntries {
|
|
|
+ return nil, fmt.Errorf("archive contains too many entries (%d > %d)", len(zr.File), MaxArchiveEntries)
|
|
|
+ }
|
|
|
+
|
|
|
+ var totalUncompressed int64
|
|
|
+ var manifestFile *zip.File
|
|
|
+
|
|
|
+ for _, f := range zr.File {
|
|
|
+ cleanName := filepath.Clean(f.Name)
|
|
|
+
|
|
|
+ // Prevent Zip Slip / Path Traversal
|
|
|
+ if strings.HasPrefix(cleanName, "..") || strings.HasPrefix(cleanName, "/") || strings.HasPrefix(cleanName, "\\") {
|
|
|
+ return nil, fmt.Errorf("illegal entry path: %s", f.Name)
|
|
|
+ }
|
|
|
+ if !strings.HasPrefix(cleanName, "debtik/") &&
|
|
|
+ !strings.HasPrefix(cleanName, "network/") &&
|
|
|
+ cleanName != "manifest.json" &&
|
|
|
+ cleanName != "debtik" &&
|
|
|
+ cleanName != "network" {
|
|
|
+ return nil, fmt.Errorf("unrecognized file outside allowed prefixes: %s", cleanName)
|
|
|
+ }
|
|
|
+
|
|
|
+ totalUncompressed += int64(f.UncompressedSize64)
|
|
|
+ if totalUncompressed > MaxArchiveBytes {
|
|
|
+ return nil, fmt.Errorf("archive uncompressed size exceeds maximum allowed (%d bytes)", MaxArchiveBytes)
|
|
|
+ }
|
|
|
+
|
|
|
+ if cleanName == "manifest.json" {
|
|
|
+ manifestFile = f
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if manifestFile == nil {
|
|
|
+ return nil, errors.New("archive is missing manifest.json")
|
|
|
+ }
|
|
|
+
|
|
|
+ rc, err := manifestFile.Open()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("cannot read manifest.json: %w", err)
|
|
|
+ }
|
|
|
+ defer rc.Close()
|
|
|
+
|
|
|
+ var manifest Manifest
|
|
|
+ if err := json.NewDecoder(rc).Decode(&manifest); err != nil {
|
|
|
+ return nil, fmt.Errorf("invalid manifest.json format: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ if manifest.Version == "" {
|
|
|
+ return nil, errors.New("manifest version is missing")
|
|
|
+ }
|
|
|
+
|
|
|
+ return &manifest, nil
|
|
|
+}
|
|
|
+
|
|
|
+// RestoreBackup inspects, creates a pre-restore safety copy, stages, and restores configurations.
|
|
|
+func (m *Manager) RestoreBackup(r io.ReaderAt, size int64) (*RestoreResult, error) {
|
|
|
+ m.mu.Lock()
|
|
|
+ defer m.mu.Unlock()
|
|
|
+
|
|
|
+ // 1. Inspect and validate zip structure
|
|
|
+ manifest, err := m.InspectBackup(r, size)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("backup validation failed: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. Create pre-restore safety backup of active config
|
|
|
+ safetyBackupPath, err := m.createPreRestoreBackupLocked()
|
|
|
+ if err != nil {
|
|
|
+ log.Printf("[Backup] Warning: failed creating pre-restore safety backup: %v", err)
|
|
|
+ } else {
|
|
|
+ log.Printf("[Backup] Created pre-restore fallback backup: %s", safetyBackupPath)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. Extract to staging directory
|
|
|
+ stagingDir, err := os.MkdirTemp("", "debtik-restore-stage-*")
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed creating staging directory: %w", err)
|
|
|
+ }
|
|
|
+ defer os.RemoveAll(stagingDir)
|
|
|
+
|
|
|
+ zr, err := zip.NewReader(r, size)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, f := range zr.File {
|
|
|
+ cleanName := filepath.Clean(f.Name)
|
|
|
+ if cleanName == "manifest.json" {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if f.FileInfo().IsDir() {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ destPath := filepath.Join(stagingDir, cleanName)
|
|
|
+ // Ensure destination path strictly within stagingDir
|
|
|
+ if !strings.HasPrefix(destPath, stagingDir+string(filepath.Separator)) {
|
|
|
+ return nil, fmt.Errorf("illegal path traversal detected in %s", f.Name)
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
|
|
+ return nil, fmt.Errorf("failed creating staging subdir: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ rc, err := f.Open()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed opening entry %s: %w", f.Name, err)
|
|
|
+ }
|
|
|
+
|
|
|
+ content, err := io.ReadAll(rc)
|
|
|
+ _ = rc.Close()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed reading entry %s: %w", f.Name, err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Determine safe mode
|
|
|
+ mode := os.FileMode(0644)
|
|
|
+ lower := strings.ToLower(cleanName)
|
|
|
+ if strings.Contains(lower, "key") || strings.Contains(lower, "users.json") || strings.Contains(lower, "ppp_secrets.json") {
|
|
|
+ mode = 0600
|
|
|
+ } else if f.Mode().Perm() != 0 {
|
|
|
+ mode = f.Mode().Perm()
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := os.WriteFile(destPath, content, mode); err != nil {
|
|
|
+ return nil, fmt.Errorf("failed writing staged file %s: %w", f.Name, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. Apply DebTik directory (/etc/debtik)
|
|
|
+ stagedDebtik := filepath.Join(stagingDir, "debtik")
|
|
|
+ if info, err := os.Stat(stagedDebtik); err == nil && info.IsDir() {
|
|
|
+ // Clean and replace /etc/debtik contents
|
|
|
+ if err := m.syncDirectory(stagedDebtik, m.debtikDir); err != nil {
|
|
|
+ return nil, fmt.Errorf("failed restoring debtik configuration: %w", err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 5. Apply Network directory (/etc/network/interfaces.d)
|
|
|
+ stagedNetwork := filepath.Join(stagingDir, "network")
|
|
|
+ if info, err := os.Stat(stagedNetwork); err == nil && info.IsDir() {
|
|
|
+ if err := os.MkdirAll(m.networkDir, 0755); err != nil {
|
|
|
+ return nil, fmt.Errorf("failed ensuring network config dir: %w", err)
|
|
|
+ }
|
|
|
+ entries, _ := os.ReadDir(stagedNetwork)
|
|
|
+ for _, entry := range entries {
|
|
|
+ if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".cfg") {
|
|
|
+ src := filepath.Join(stagedNetwork, entry.Name())
|
|
|
+ dst := filepath.Join(m.networkDir, entry.Name())
|
|
|
+ data, err := os.ReadFile(src)
|
|
|
+ if err == nil {
|
|
|
+ _ = os.WriteFile(dst, data, 0644)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ result := &RestoreResult{
|
|
|
+ Success: true,
|
|
|
+ Message: "Configuration restored successfully. Reloading appliance controller...",
|
|
|
+ Manifest: manifest,
|
|
|
+ SafetyBackup: safetyBackupPath,
|
|
|
+ RestoredAt: time.Now().UTC(),
|
|
|
+ }
|
|
|
+
|
|
|
+ return result, nil
|
|
|
+}
|
|
|
+
|
|
|
+// syncDirectory copies srcDir into dstDir, cleaning obsolete files while preserving security permissions.
|
|
|
+func (m *Manager) syncDirectory(srcDir, dstDir string) error {
|
|
|
+ if err := os.MkdirAll(dstDir, 0755); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+
|
|
|
+ // Wipe existing files in dstDir to avoid stale or orphaned configs
|
|
|
+ existing, err := os.ReadDir(dstDir)
|
|
|
+ if err == nil {
|
|
|
+ for _, e := range existing {
|
|
|
+ _ = os.RemoveAll(filepath.Join(dstDir, e.Name()))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+
|
|
|
+ rel, err := filepath.Rel(srcDir, path)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ target := filepath.Join(dstDir, rel)
|
|
|
+
|
|
|
+ if info.IsDir() {
|
|
|
+ dirPerm := os.FileMode(0755)
|
|
|
+ if strings.Contains(rel, "certs") {
|
|
|
+ dirPerm = 0700
|
|
|
+ }
|
|
|
+ return os.MkdirAll(target, dirPerm)
|
|
|
+ }
|
|
|
+
|
|
|
+ data, err := os.ReadFile(path)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+
|
|
|
+ mode := info.Mode().Perm()
|
|
|
+ lower := strings.ToLower(rel)
|
|
|
+ if strings.Contains(lower, "key") || strings.Contains(lower, "users.json") || strings.Contains(lower, "ppp_secrets.json") {
|
|
|
+ mode = 0600
|
|
|
+ } else if mode == 0 {
|
|
|
+ mode = 0644
|
|
|
+ }
|
|
|
+
|
|
|
+ return os.WriteFile(target, data, mode)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// TriggerRestart initiates an asynchronous service reload or restart.
|
|
|
+func (m *Manager) TriggerRestart(delay time.Duration) {
|
|
|
+ go func() {
|
|
|
+ time.Sleep(delay)
|
|
|
+ log.Printf("[Backup] Initiating controller restart following configuration restore...")
|
|
|
+ if m.restartFunc != nil {
|
|
|
+ if err := m.restartFunc(); err != nil {
|
|
|
+ log.Printf("[Backup] Restart hook returned error: %v", err)
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+ cmd := exec.Command("systemctl", "restart", "debtik")
|
|
|
+ if err := cmd.Run(); err != nil {
|
|
|
+ log.Printf("[Backup] systemctl restart debtik failed: %v", err)
|
|
|
+ }
|
|
|
+ }()
|
|
|
+}
|
|
|
+
|
|
|
+// ListLocalBackups returns a list of stored backups in the backupStore.
|
|
|
+func (m *Manager) ListLocalBackups() ([]BackupInfo, error) {
|
|
|
+ if err := os.MkdirAll(m.backupStore, 0700); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+
|
|
|
+ entries, err := os.ReadDir(m.backupStore)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+
|
|
|
+ var results []BackupInfo
|
|
|
+ for _, e := range entries {
|
|
|
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".zip") {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ info, err := e.Info()
|
|
|
+ if err != nil {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ filePath := filepath.Join(m.backupStore, e.Name())
|
|
|
+ bInfo := BackupInfo{
|
|
|
+ Filename: e.Name(),
|
|
|
+ Size: info.Size(),
|
|
|
+ ModTime: info.ModTime().UTC(),
|
|
|
+ }
|
|
|
+
|
|
|
+ // Attempt reading manifest for richer metadata
|
|
|
+ if f, err := os.Open(filePath); err == nil {
|
|
|
+ if zr, err := zip.NewReader(f, info.Size()); err == nil {
|
|
|
+ for _, zf := range zr.File {
|
|
|
+ if zf.Name == "manifest.json" {
|
|
|
+ if rc, err := zf.Open(); err == nil {
|
|
|
+ var mf Manifest
|
|
|
+ if err := json.NewDecoder(rc).Decode(&mf); err == nil {
|
|
|
+ bInfo.DebTikVersion = mf.DebTikVersion
|
|
|
+ bInfo.Hostname = mf.Hostname
|
|
|
+ bInfo.Comment = mf.Comment
|
|
|
+ }
|
|
|
+ _ = rc.Close()
|
|
|
+ }
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ _ = f.Close()
|
|
|
+ }
|
|
|
+
|
|
|
+ results = append(results, bInfo)
|
|
|
+ }
|
|
|
+
|
|
|
+ sort.Slice(results, func(i, j int) bool {
|
|
|
+ return results[i].ModTime.After(results[j].ModTime)
|
|
|
+ })
|
|
|
+
|
|
|
+ return results, nil
|
|
|
+}
|
|
|
+
|
|
|
+// GetLocalBackup returns the full path of a stored backup file after sanitization.
|
|
|
+func (m *Manager) GetLocalBackup(filename string) (string, error) {
|
|
|
+ cleanName := sanitizeFilename(filename)
|
|
|
+ if !strings.HasSuffix(cleanName, ".zip") {
|
|
|
+ return "", errors.New("backup filename must have .zip extension")
|
|
|
+ }
|
|
|
+
|
|
|
+ fullPath := filepath.Join(m.backupStore, cleanName)
|
|
|
+ if info, err := os.Stat(fullPath); err != nil || info.IsDir() {
|
|
|
+ return "", fmt.Errorf("backup file %q not found", cleanName)
|
|
|
+ }
|
|
|
+
|
|
|
+ return fullPath, nil
|
|
|
+}
|
|
|
+
|
|
|
+// sanitizeFilename restricts filenames to safe alphanumeric characters, dashes, underscores, and dots.
|
|
|
+func sanitizeFilename(name string) string {
|
|
|
+ name = filepath.Base(name)
|
|
|
+ var b strings.Builder
|
|
|
+ for _, r := range name {
|
|
|
+ if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
|
|
|
+ b.WriteRune(r)
|
|
|
+ } else {
|
|
|
+ b.WriteRune('_')
|
|
|
+ }
|
|
|
+ }
|
|
|
+ res := b.String()
|
|
|
+ if res == "" || res == "." || res == ".." {
|
|
|
+ return "backup.zip"
|
|
|
+ }
|
|
|
+ return res
|
|
|
+}
|