| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678 |
- 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
- }
|