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 }