manager.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  1. package backup
  2. import (
  3. "archive/zip"
  4. "bytes"
  5. "crypto/sha256"
  6. "encoding/hex"
  7. "encoding/json"
  8. "errors"
  9. "fmt"
  10. "io"
  11. "log"
  12. "os"
  13. "os/exec"
  14. "path/filepath"
  15. "sort"
  16. "strings"
  17. "sync"
  18. "time"
  19. release "debtik"
  20. )
  21. const (
  22. DefaultDebtikDir = "/etc/debtik"
  23. DefaultNetworkDir = "/etc/network/interfaces.d"
  24. DefaultBackupStore = "/var/lib/debtik/backups"
  25. MaxArchiveEntries = 5000
  26. MaxArchiveBytes = 100 * 1024 * 1024 // 100 MB max uncompressed
  27. BackupFormatVer = "1.0"
  28. )
  29. // Manager handles backup generation, inspection, restoration, and local retention.
  30. type Manager struct {
  31. debtikDir string
  32. networkDir string
  33. backupStore string
  34. restartFunc func() error
  35. mu sync.Mutex
  36. }
  37. // NewManager creates a new backup Manager with the provided or default directories.
  38. func NewManager(debtikDir, networkDir, backupStore string, restartFunc func() error) *Manager {
  39. if debtikDir == "" {
  40. debtikDir = DefaultDebtikDir
  41. }
  42. if networkDir == "" {
  43. networkDir = DefaultNetworkDir
  44. }
  45. if backupStore == "" {
  46. backupStore = DefaultBackupStore
  47. }
  48. return &Manager{
  49. debtikDir: filepath.Clean(debtikDir),
  50. networkDir: filepath.Clean(networkDir),
  51. backupStore: filepath.Clean(backupStore),
  52. restartFunc: restartFunc,
  53. }
  54. }
  55. // CreateBackup generates an in-memory zip archive containing all DebTik configurations.
  56. func (m *Manager) CreateBackup(comment string) ([]byte, *Manifest, error) {
  57. m.mu.Lock()
  58. defer m.mu.Unlock()
  59. return m.createBackupLocked(comment)
  60. }
  61. func (m *Manager) createBackupLocked(comment string) ([]byte, *Manifest, error) {
  62. hostname, _ := os.Hostname()
  63. if hostname == "" {
  64. hostname = "DebTik"
  65. }
  66. manifest := &Manifest{
  67. Version: BackupFormatVer,
  68. DebTikVersion: release.Version(),
  69. Hostname: hostname,
  70. CreatedAt: time.Now().UTC(),
  71. Comment: strings.TrimSpace(comment),
  72. Files: make([]FileInfo, 0),
  73. }
  74. buf := new(bytes.Buffer)
  75. zw := zip.NewWriter(buf)
  76. // 1. Collect /etc/debtik configuration tree
  77. if err := m.archiveDebtikTree(zw, manifest); err != nil {
  78. _ = zw.Close()
  79. return nil, nil, fmt.Errorf("failed archiving debtik config tree: %w", err)
  80. }
  81. // 2. Collect /etc/network/interfaces.d configuration files
  82. if err := m.archiveNetworkFiles(zw, manifest); err != nil {
  83. _ = zw.Close()
  84. return nil, nil, fmt.Errorf("failed archiving network config files: %w", err)
  85. }
  86. // 3. Write manifest.json
  87. manifestBytes, err := json.MarshalIndent(manifest, "", " ")
  88. if err != nil {
  89. _ = zw.Close()
  90. return nil, nil, fmt.Errorf("failed serializing manifest: %w", err)
  91. }
  92. manifestHeader := &zip.FileHeader{
  93. Name: "manifest.json",
  94. Method: zip.Deflate,
  95. Modified: manifest.CreatedAt,
  96. }
  97. manifestHeader.SetMode(0644)
  98. w, err := zw.CreateHeader(manifestHeader)
  99. if err != nil {
  100. _ = zw.Close()
  101. return nil, nil, fmt.Errorf("failed creating manifest in zip: %w", err)
  102. }
  103. if _, err := w.Write(manifestBytes); err != nil {
  104. _ = zw.Close()
  105. return nil, nil, fmt.Errorf("failed writing manifest to zip: %w", err)
  106. }
  107. if err := zw.Close(); err != nil {
  108. return nil, nil, fmt.Errorf("failed finalizing zip archive: %w", err)
  109. }
  110. return buf.Bytes(), manifest, nil
  111. }
  112. func (m *Manager) archiveDebtikTree(zw *zip.Writer, manifest *Manifest) error {
  113. if _, err := os.Stat(m.debtikDir); os.IsNotExist(err) {
  114. return nil
  115. }
  116. return filepath.Walk(m.debtikDir, func(path string, info os.FileInfo, err error) error {
  117. if err != nil {
  118. return err
  119. }
  120. if info.IsDir() {
  121. return nil
  122. }
  123. // Skip sockets, named pipes, etc.
  124. if !info.Mode().IsRegular() {
  125. return nil
  126. }
  127. rel, err := filepath.Rel(m.debtikDir, path)
  128. if err != nil {
  129. return err
  130. }
  131. rel = filepath.ToSlash(rel)
  132. zipPath := "debtik/" + rel
  133. data, err := os.ReadFile(path)
  134. if err != nil {
  135. return fmt.Errorf("cannot read file %s: %w", path, err)
  136. }
  137. hash := sha256.Sum256(data)
  138. hashStr := hex.EncodeToString(hash[:])
  139. hdr := &zip.FileHeader{
  140. Name: zipPath,
  141. Method: zip.Deflate,
  142. Modified: info.ModTime().UTC(),
  143. }
  144. hdr.SetMode(info.Mode().Perm())
  145. w, err := zw.CreateHeader(hdr)
  146. if err != nil {
  147. return err
  148. }
  149. if _, err := w.Write(data); err != nil {
  150. return err
  151. }
  152. manifest.Files = append(manifest.Files, FileInfo{
  153. Path: zipPath,
  154. Size: int64(len(data)),
  155. Mode: uint32(info.Mode().Perm()),
  156. SHA256: hashStr,
  157. })
  158. return nil
  159. })
  160. }
  161. func (m *Manager) archiveNetworkFiles(zw *zip.Writer, manifest *Manifest) error {
  162. if _, err := os.Stat(m.networkDir); os.IsNotExist(err) {
  163. return nil
  164. }
  165. entries, err := os.ReadDir(m.networkDir)
  166. if err != nil {
  167. return err
  168. }
  169. for _, entry := range entries {
  170. if entry.IsDir() {
  171. continue
  172. }
  173. name := entry.Name()
  174. // Only archive .cfg files (e.g. interfaces.cfg, vlans.cfg)
  175. if !strings.HasSuffix(name, ".cfg") {
  176. continue
  177. }
  178. filePath := filepath.Join(m.networkDir, name)
  179. info, err := entry.Info()
  180. if err != nil {
  181. return err
  182. }
  183. if !info.Mode().IsRegular() {
  184. return fmt.Errorf("network backup path is not a regular file: %s", name)
  185. }
  186. data, err := os.ReadFile(filePath)
  187. if err != nil {
  188. return fmt.Errorf("cannot read network config %s: %w", name, err)
  189. }
  190. hash := sha256.Sum256(data)
  191. hashStr := hex.EncodeToString(hash[:])
  192. zipPath := "network/" + name
  193. hdr := &zip.FileHeader{
  194. Name: zipPath,
  195. Method: zip.Deflate,
  196. Modified: info.ModTime().UTC(),
  197. }
  198. hdr.SetMode(info.Mode().Perm())
  199. w, err := zw.CreateHeader(hdr)
  200. if err != nil {
  201. return err
  202. }
  203. if _, err := w.Write(data); err != nil {
  204. return err
  205. }
  206. manifest.Files = append(manifest.Files, FileInfo{
  207. Path: zipPath,
  208. Size: int64(len(data)),
  209. Mode: uint32(info.Mode().Perm()),
  210. SHA256: hashStr,
  211. })
  212. }
  213. return nil
  214. }
  215. // SaveBackup creates a backup and saves it to the local backupStore directory.
  216. func (m *Manager) SaveBackup(name, comment string) (string, *Manifest, error) {
  217. m.mu.Lock()
  218. defer m.mu.Unlock()
  219. return m.saveBackupLocked(name, comment)
  220. }
  221. func (m *Manager) saveBackupLocked(name, comment string) (string, *Manifest, error) {
  222. if err := os.MkdirAll(m.backupStore, 0700); err != nil {
  223. return "", nil, fmt.Errorf("failed creating backup storage directory: %w", err)
  224. }
  225. data, manifest, err := m.createBackupLocked(comment)
  226. if err != nil {
  227. return "", nil, err
  228. }
  229. if name == "" {
  230. name = fmt.Sprintf("debtik-backup-%s-%s.zip", sanitizeFilename(manifest.Hostname), manifest.CreatedAt.Format("20060102-150405"))
  231. }
  232. if !strings.HasSuffix(name, ".zip") {
  233. name += ".zip"
  234. }
  235. name = sanitizeFilename(name)
  236. targetPath := filepath.Join(m.backupStore, name)
  237. if err := os.WriteFile(targetPath, data, 0600); err != nil {
  238. return "", nil, fmt.Errorf("failed writing backup file: %w", err)
  239. }
  240. return targetPath, manifest, nil
  241. }
  242. // CreatePreRestoreBackup creates a safety backup before applying any restored configurations.
  243. // It keeps the latest 5 pre-restore backups, pruning older ones.
  244. func (m *Manager) CreatePreRestoreBackup() (string, error) {
  245. m.mu.Lock()
  246. defer m.mu.Unlock()
  247. return m.createPreRestoreBackupLocked()
  248. }
  249. func (m *Manager) createPreRestoreBackupLocked() (string, error) {
  250. if err := os.MkdirAll(m.backupStore, 0700); err != nil {
  251. return "", err
  252. }
  253. hostname, _ := os.Hostname()
  254. if hostname == "" {
  255. hostname = "DebTik"
  256. }
  257. fileName := fmt.Sprintf("pre-restore-%s-%s-%d.zip", sanitizeFilename(hostname), time.Now().UTC().Format("20060102-150405"), time.Now().UnixNano()%1000000)
  258. savedPath, _, err := m.saveBackupLocked(fileName, "Automatic pre-restore safety fallback")
  259. if err != nil {
  260. return "", err
  261. }
  262. // Prune older pre-restore backups, keeping latest 5
  263. m.prunePreRestoreBackups(5)
  264. return savedPath, nil
  265. }
  266. func (m *Manager) prunePreRestoreBackups(keep int) {
  267. entries, err := os.ReadDir(m.backupStore)
  268. if err != nil {
  269. return
  270. }
  271. type fileItem struct {
  272. name string
  273. modTime time.Time
  274. }
  275. var preRestores []fileItem
  276. for _, e := range entries {
  277. if e.IsDir() || !strings.HasPrefix(e.Name(), "pre-restore-") || !strings.HasSuffix(e.Name(), ".zip") {
  278. continue
  279. }
  280. info, err := e.Info()
  281. if err == nil {
  282. preRestores = append(preRestores, fileItem{name: e.Name(), modTime: info.ModTime()})
  283. }
  284. }
  285. if len(preRestores) <= keep {
  286. return
  287. }
  288. sort.Slice(preRestores, func(i, j int) bool {
  289. return preRestores[i].modTime.After(preRestores[j].modTime)
  290. })
  291. for i := keep; i < len(preRestores); i++ {
  292. _ = os.Remove(filepath.Join(m.backupStore, preRestores[i].name))
  293. }
  294. }
  295. // InspectBackup verifies the integrity of an archive and extracts its manifest without restoring.
  296. func (m *Manager) InspectBackup(r io.ReaderAt, size int64) (*Manifest, error) {
  297. zr, err := zip.NewReader(r, size)
  298. if err != nil {
  299. return nil, fmt.Errorf("invalid zip archive: %w", err)
  300. }
  301. if len(zr.File) > MaxArchiveEntries {
  302. return nil, fmt.Errorf("archive contains too many entries (%d > %d)", len(zr.File), MaxArchiveEntries)
  303. }
  304. var totalUncompressed int64
  305. var manifestFile *zip.File
  306. for _, f := range zr.File {
  307. cleanName := filepath.Clean(f.Name)
  308. // Prevent Zip Slip / Path Traversal
  309. if strings.HasPrefix(cleanName, "..") || strings.HasPrefix(cleanName, "/") || strings.HasPrefix(cleanName, "\\") {
  310. return nil, fmt.Errorf("illegal entry path: %s", f.Name)
  311. }
  312. if !strings.HasPrefix(cleanName, "debtik/") &&
  313. !strings.HasPrefix(cleanName, "network/") &&
  314. cleanName != "manifest.json" &&
  315. cleanName != "debtik" &&
  316. cleanName != "network" {
  317. return nil, fmt.Errorf("unrecognized file outside allowed prefixes: %s", cleanName)
  318. }
  319. totalUncompressed += int64(f.UncompressedSize64)
  320. if totalUncompressed > MaxArchiveBytes {
  321. return nil, fmt.Errorf("archive uncompressed size exceeds maximum allowed (%d bytes)", MaxArchiveBytes)
  322. }
  323. if cleanName == "manifest.json" {
  324. manifestFile = f
  325. }
  326. }
  327. if manifestFile == nil {
  328. return nil, errors.New("archive is missing manifest.json")
  329. }
  330. rc, err := manifestFile.Open()
  331. if err != nil {
  332. return nil, fmt.Errorf("cannot read manifest.json: %w", err)
  333. }
  334. defer rc.Close()
  335. var manifest Manifest
  336. if err := json.NewDecoder(rc).Decode(&manifest); err != nil {
  337. return nil, fmt.Errorf("invalid manifest.json format: %w", err)
  338. }
  339. if manifest.Version == "" {
  340. return nil, errors.New("manifest version is missing")
  341. }
  342. return &manifest, nil
  343. }
  344. // RestoreBackup inspects, creates a pre-restore safety copy, stages, and restores configurations.
  345. func (m *Manager) RestoreBackup(r io.ReaderAt, size int64) (*RestoreResult, error) {
  346. m.mu.Lock()
  347. defer m.mu.Unlock()
  348. // 1. Inspect and validate zip structure
  349. manifest, err := m.InspectBackup(r, size)
  350. if err != nil {
  351. return nil, fmt.Errorf("backup validation failed: %w", err)
  352. }
  353. // 2. Create pre-restore safety backup of active config
  354. safetyBackupPath, err := m.createPreRestoreBackupLocked()
  355. if err != nil {
  356. return nil, fmt.Errorf("failed creating pre-restore safety backup: %w", err)
  357. }
  358. log.Printf("[Backup] Created pre-restore fallback backup: %s", safetyBackupPath)
  359. // 3. Extract to staging directory
  360. stagingDir, err := os.MkdirTemp("", "debtik-restore-stage-*")
  361. if err != nil {
  362. return nil, fmt.Errorf("failed creating staging directory: %w", err)
  363. }
  364. defer os.RemoveAll(stagingDir)
  365. zr, err := zip.NewReader(r, size)
  366. if err != nil {
  367. return nil, err
  368. }
  369. for _, f := range zr.File {
  370. cleanName := filepath.Clean(f.Name)
  371. if cleanName == "manifest.json" {
  372. continue
  373. }
  374. if f.FileInfo().IsDir() {
  375. continue
  376. }
  377. destPath := filepath.Join(stagingDir, cleanName)
  378. // Ensure destination path strictly within stagingDir
  379. if !strings.HasPrefix(destPath, stagingDir+string(filepath.Separator)) {
  380. return nil, fmt.Errorf("illegal path traversal detected in %s", f.Name)
  381. }
  382. if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
  383. return nil, fmt.Errorf("failed creating staging subdir: %w", err)
  384. }
  385. rc, err := f.Open()
  386. if err != nil {
  387. return nil, fmt.Errorf("failed opening entry %s: %w", f.Name, err)
  388. }
  389. content, err := io.ReadAll(rc)
  390. _ = rc.Close()
  391. if err != nil {
  392. return nil, fmt.Errorf("failed reading entry %s: %w", f.Name, err)
  393. }
  394. // Determine safe mode
  395. mode := os.FileMode(0644)
  396. lower := strings.ToLower(cleanName)
  397. if strings.Contains(lower, "key") || strings.Contains(lower, "users.json") || strings.Contains(lower, "ppp_secrets.json") {
  398. mode = 0600
  399. } else if f.Mode().Perm() != 0 {
  400. mode = f.Mode().Perm()
  401. }
  402. if err := os.WriteFile(destPath, content, mode); err != nil {
  403. return nil, fmt.Errorf("failed writing staged file %s: %w", f.Name, err)
  404. }
  405. }
  406. // Apply from sibling staging, rolling back disk changes on a failed step.
  407. if err := m.applyStagedRestore(stagingDir, nil); err != nil {
  408. return nil, fmt.Errorf("restore failed (safety backup %s): %w", safetyBackupPath, err)
  409. }
  410. result := &RestoreResult{
  411. Success: true,
  412. Message: "Configuration restored successfully. Reloading appliance controller...",
  413. Manifest: manifest,
  414. SafetyBackup: safetyBackupPath,
  415. RestoredAt: time.Now().UTC(),
  416. }
  417. return result, nil
  418. }
  419. // syncDirectory copies srcDir into dstDir, cleaning obsolete files while preserving security permissions.
  420. func (m *Manager) syncDirectory(srcDir, dstDir string) error {
  421. if err := os.MkdirAll(dstDir, 0755); err != nil {
  422. return err
  423. }
  424. // Wipe existing files in dstDir to avoid stale or orphaned configs
  425. existing, err := os.ReadDir(dstDir)
  426. if err != nil {
  427. return err
  428. }
  429. for _, e := range existing {
  430. if err := os.RemoveAll(filepath.Join(dstDir, e.Name())); err != nil {
  431. return err
  432. }
  433. }
  434. return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
  435. if err != nil {
  436. return err
  437. }
  438. rel, err := filepath.Rel(srcDir, path)
  439. if err != nil {
  440. return err
  441. }
  442. target := filepath.Join(dstDir, rel)
  443. if info.IsDir() {
  444. dirPerm := os.FileMode(0755)
  445. if strings.Contains(rel, "certs") {
  446. dirPerm = 0700
  447. }
  448. return os.MkdirAll(target, dirPerm)
  449. }
  450. data, err := os.ReadFile(path)
  451. if err != nil {
  452. return err
  453. }
  454. mode := info.Mode().Perm()
  455. lower := strings.ToLower(rel)
  456. if strings.Contains(lower, "key") || strings.Contains(lower, "users.json") || strings.Contains(lower, "ppp_secrets.json") {
  457. mode = 0600
  458. } else if mode == 0 {
  459. mode = 0644
  460. }
  461. return os.WriteFile(target, data, mode)
  462. })
  463. }
  464. // TriggerRestart initiates an asynchronous service reload or restart.
  465. func (m *Manager) TriggerRestart(delay time.Duration) {
  466. go func() {
  467. time.Sleep(delay)
  468. log.Printf("[Backup] Initiating controller restart following configuration restore...")
  469. if m.restartFunc != nil {
  470. if err := m.restartFunc(); err != nil {
  471. log.Printf("[Backup] Restart hook returned error: %v", err)
  472. }
  473. return
  474. }
  475. cmd := exec.Command("systemctl", "restart", "debtik")
  476. if err := cmd.Run(); err != nil {
  477. log.Printf("[Backup] systemctl restart debtik failed: %v", err)
  478. }
  479. }()
  480. }
  481. // ListLocalBackups returns a list of stored backups in the backupStore.
  482. func (m *Manager) ListLocalBackups() ([]BackupInfo, error) {
  483. if err := os.MkdirAll(m.backupStore, 0700); err != nil {
  484. return nil, err
  485. }
  486. entries, err := os.ReadDir(m.backupStore)
  487. if err != nil {
  488. return nil, err
  489. }
  490. var results []BackupInfo
  491. for _, e := range entries {
  492. if e.IsDir() || !strings.HasSuffix(e.Name(), ".zip") {
  493. continue
  494. }
  495. info, err := e.Info()
  496. if err != nil {
  497. continue
  498. }
  499. filePath := filepath.Join(m.backupStore, e.Name())
  500. bInfo := BackupInfo{
  501. Filename: e.Name(),
  502. Size: info.Size(),
  503. ModTime: info.ModTime().UTC(),
  504. }
  505. // Attempt reading manifest for richer metadata
  506. if f, err := os.Open(filePath); err == nil {
  507. if zr, err := zip.NewReader(f, info.Size()); err == nil {
  508. for _, zf := range zr.File {
  509. if zf.Name == "manifest.json" {
  510. if rc, err := zf.Open(); err == nil {
  511. var mf Manifest
  512. if err := json.NewDecoder(rc).Decode(&mf); err == nil {
  513. bInfo.DebTikVersion = mf.DebTikVersion
  514. bInfo.Hostname = mf.Hostname
  515. bInfo.Comment = mf.Comment
  516. }
  517. _ = rc.Close()
  518. }
  519. break
  520. }
  521. }
  522. }
  523. _ = f.Close()
  524. }
  525. results = append(results, bInfo)
  526. }
  527. sort.Slice(results, func(i, j int) bool {
  528. return results[i].ModTime.After(results[j].ModTime)
  529. })
  530. return results, nil
  531. }
  532. // GetLocalBackup returns the full path of a stored backup file after sanitization.
  533. func (m *Manager) GetLocalBackup(filename string) (string, error) {
  534. cleanName := sanitizeFilename(filename)
  535. if !strings.HasSuffix(cleanName, ".zip") {
  536. return "", errors.New("backup filename must have .zip extension")
  537. }
  538. fullPath := filepath.Join(m.backupStore, cleanName)
  539. if info, err := os.Stat(fullPath); err != nil || info.IsDir() {
  540. return "", fmt.Errorf("backup file %q not found", cleanName)
  541. }
  542. return fullPath, nil
  543. }
  544. // sanitizeFilename restricts filenames to safe alphanumeric characters, dashes, underscores, and dots.
  545. func sanitizeFilename(name string) string {
  546. name = filepath.Base(name)
  547. var b strings.Builder
  548. for _, r := range name {
  549. if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
  550. b.WriteRune(r)
  551. } else {
  552. b.WriteRune('_')
  553. }
  554. }
  555. res := b.String()
  556. if res == "" || res == "." || res == ".." {
  557. return "backup.zip"
  558. }
  559. return res
  560. }