manager.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  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 nil
  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 || !info.Mode().IsRegular() {
  181. continue
  182. }
  183. data, err := os.ReadFile(filePath)
  184. if err != nil {
  185. continue
  186. }
  187. hash := sha256.Sum256(data)
  188. hashStr := hex.EncodeToString(hash[:])
  189. zipPath := "network/" + name
  190. hdr := &zip.FileHeader{
  191. Name: zipPath,
  192. Method: zip.Deflate,
  193. Modified: info.ModTime().UTC(),
  194. }
  195. hdr.SetMode(info.Mode().Perm())
  196. w, err := zw.CreateHeader(hdr)
  197. if err != nil {
  198. return err
  199. }
  200. if _, err := w.Write(data); err != nil {
  201. return err
  202. }
  203. manifest.Files = append(manifest.Files, FileInfo{
  204. Path: zipPath,
  205. Size: int64(len(data)),
  206. Mode: uint32(info.Mode().Perm()),
  207. SHA256: hashStr,
  208. })
  209. }
  210. return nil
  211. }
  212. // SaveBackup creates a backup and saves it to the local backupStore directory.
  213. func (m *Manager) SaveBackup(name, comment string) (string, *Manifest, error) {
  214. m.mu.Lock()
  215. defer m.mu.Unlock()
  216. return m.saveBackupLocked(name, comment)
  217. }
  218. func (m *Manager) saveBackupLocked(name, comment string) (string, *Manifest, error) {
  219. if err := os.MkdirAll(m.backupStore, 0700); err != nil {
  220. return "", nil, fmt.Errorf("failed creating backup storage directory: %w", err)
  221. }
  222. data, manifest, err := m.createBackupLocked(comment)
  223. if err != nil {
  224. return "", nil, err
  225. }
  226. if name == "" {
  227. name = fmt.Sprintf("debtik-backup-%s-%s.zip", sanitizeFilename(manifest.Hostname), manifest.CreatedAt.Format("20060102-150405"))
  228. }
  229. if !strings.HasSuffix(name, ".zip") {
  230. name += ".zip"
  231. }
  232. name = sanitizeFilename(name)
  233. targetPath := filepath.Join(m.backupStore, name)
  234. if err := os.WriteFile(targetPath, data, 0600); err != nil {
  235. return "", nil, fmt.Errorf("failed writing backup file: %w", err)
  236. }
  237. return targetPath, manifest, nil
  238. }
  239. // CreatePreRestoreBackup creates a safety backup before applying any restored configurations.
  240. // It keeps the latest 5 pre-restore backups, pruning older ones.
  241. func (m *Manager) CreatePreRestoreBackup() (string, error) {
  242. m.mu.Lock()
  243. defer m.mu.Unlock()
  244. return m.createPreRestoreBackupLocked()
  245. }
  246. func (m *Manager) createPreRestoreBackupLocked() (string, error) {
  247. if err := os.MkdirAll(m.backupStore, 0700); err != nil {
  248. return "", err
  249. }
  250. hostname, _ := os.Hostname()
  251. if hostname == "" {
  252. hostname = "DebTik"
  253. }
  254. fileName := fmt.Sprintf("pre-restore-%s-%s-%d.zip", sanitizeFilename(hostname), time.Now().UTC().Format("20060102-150405"), time.Now().UnixNano()%1000000)
  255. savedPath, _, err := m.saveBackupLocked(fileName, "Automatic pre-restore safety fallback")
  256. if err != nil {
  257. return "", err
  258. }
  259. // Prune older pre-restore backups, keeping latest 5
  260. m.prunePreRestoreBackups(5)
  261. return savedPath, nil
  262. }
  263. func (m *Manager) prunePreRestoreBackups(keep int) {
  264. entries, err := os.ReadDir(m.backupStore)
  265. if err != nil {
  266. return
  267. }
  268. type fileItem struct {
  269. name string
  270. modTime time.Time
  271. }
  272. var preRestores []fileItem
  273. for _, e := range entries {
  274. if e.IsDir() || !strings.HasPrefix(e.Name(), "pre-restore-") || !strings.HasSuffix(e.Name(), ".zip") {
  275. continue
  276. }
  277. info, err := e.Info()
  278. if err == nil {
  279. preRestores = append(preRestores, fileItem{name: e.Name(), modTime: info.ModTime()})
  280. }
  281. }
  282. if len(preRestores) <= keep {
  283. return
  284. }
  285. sort.Slice(preRestores, func(i, j int) bool {
  286. return preRestores[i].modTime.After(preRestores[j].modTime)
  287. })
  288. for i := keep; i < len(preRestores); i++ {
  289. _ = os.Remove(filepath.Join(m.backupStore, preRestores[i].name))
  290. }
  291. }
  292. // InspectBackup verifies the integrity of an archive and extracts its manifest without restoring.
  293. func (m *Manager) InspectBackup(r io.ReaderAt, size int64) (*Manifest, error) {
  294. zr, err := zip.NewReader(r, size)
  295. if err != nil {
  296. return nil, fmt.Errorf("invalid zip archive: %w", err)
  297. }
  298. if len(zr.File) > MaxArchiveEntries {
  299. return nil, fmt.Errorf("archive contains too many entries (%d > %d)", len(zr.File), MaxArchiveEntries)
  300. }
  301. var totalUncompressed int64
  302. var manifestFile *zip.File
  303. for _, f := range zr.File {
  304. cleanName := filepath.Clean(f.Name)
  305. // Prevent Zip Slip / Path Traversal
  306. if strings.HasPrefix(cleanName, "..") || strings.HasPrefix(cleanName, "/") || strings.HasPrefix(cleanName, "\\") {
  307. return nil, fmt.Errorf("illegal entry path: %s", f.Name)
  308. }
  309. if !strings.HasPrefix(cleanName, "debtik/") &&
  310. !strings.HasPrefix(cleanName, "network/") &&
  311. cleanName != "manifest.json" &&
  312. cleanName != "debtik" &&
  313. cleanName != "network" {
  314. return nil, fmt.Errorf("unrecognized file outside allowed prefixes: %s", cleanName)
  315. }
  316. totalUncompressed += int64(f.UncompressedSize64)
  317. if totalUncompressed > MaxArchiveBytes {
  318. return nil, fmt.Errorf("archive uncompressed size exceeds maximum allowed (%d bytes)", MaxArchiveBytes)
  319. }
  320. if cleanName == "manifest.json" {
  321. manifestFile = f
  322. }
  323. }
  324. if manifestFile == nil {
  325. return nil, errors.New("archive is missing manifest.json")
  326. }
  327. rc, err := manifestFile.Open()
  328. if err != nil {
  329. return nil, fmt.Errorf("cannot read manifest.json: %w", err)
  330. }
  331. defer rc.Close()
  332. var manifest Manifest
  333. if err := json.NewDecoder(rc).Decode(&manifest); err != nil {
  334. return nil, fmt.Errorf("invalid manifest.json format: %w", err)
  335. }
  336. if manifest.Version == "" {
  337. return nil, errors.New("manifest version is missing")
  338. }
  339. return &manifest, nil
  340. }
  341. // RestoreBackup inspects, creates a pre-restore safety copy, stages, and restores configurations.
  342. func (m *Manager) RestoreBackup(r io.ReaderAt, size int64) (*RestoreResult, error) {
  343. m.mu.Lock()
  344. defer m.mu.Unlock()
  345. // 1. Inspect and validate zip structure
  346. manifest, err := m.InspectBackup(r, size)
  347. if err != nil {
  348. return nil, fmt.Errorf("backup validation failed: %w", err)
  349. }
  350. // 2. Create pre-restore safety backup of active config
  351. safetyBackupPath, err := m.createPreRestoreBackupLocked()
  352. if err != nil {
  353. log.Printf("[Backup] Warning: failed creating pre-restore safety backup: %v", err)
  354. } else {
  355. log.Printf("[Backup] Created pre-restore fallback backup: %s", safetyBackupPath)
  356. }
  357. // 3. Extract to staging directory
  358. stagingDir, err := os.MkdirTemp("", "debtik-restore-stage-*")
  359. if err != nil {
  360. return nil, fmt.Errorf("failed creating staging directory: %w", err)
  361. }
  362. defer os.RemoveAll(stagingDir)
  363. zr, err := zip.NewReader(r, size)
  364. if err != nil {
  365. return nil, err
  366. }
  367. for _, f := range zr.File {
  368. cleanName := filepath.Clean(f.Name)
  369. if cleanName == "manifest.json" {
  370. continue
  371. }
  372. if f.FileInfo().IsDir() {
  373. continue
  374. }
  375. destPath := filepath.Join(stagingDir, cleanName)
  376. // Ensure destination path strictly within stagingDir
  377. if !strings.HasPrefix(destPath, stagingDir+string(filepath.Separator)) {
  378. return nil, fmt.Errorf("illegal path traversal detected in %s", f.Name)
  379. }
  380. if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
  381. return nil, fmt.Errorf("failed creating staging subdir: %w", err)
  382. }
  383. rc, err := f.Open()
  384. if err != nil {
  385. return nil, fmt.Errorf("failed opening entry %s: %w", f.Name, err)
  386. }
  387. content, err := io.ReadAll(rc)
  388. _ = rc.Close()
  389. if err != nil {
  390. return nil, fmt.Errorf("failed reading entry %s: %w", f.Name, err)
  391. }
  392. // Determine safe mode
  393. mode := os.FileMode(0644)
  394. lower := strings.ToLower(cleanName)
  395. if strings.Contains(lower, "key") || strings.Contains(lower, "users.json") || strings.Contains(lower, "ppp_secrets.json") {
  396. mode = 0600
  397. } else if f.Mode().Perm() != 0 {
  398. mode = f.Mode().Perm()
  399. }
  400. if err := os.WriteFile(destPath, content, mode); err != nil {
  401. return nil, fmt.Errorf("failed writing staged file %s: %w", f.Name, err)
  402. }
  403. }
  404. // 4. Apply DebTik directory (/etc/debtik)
  405. stagedDebtik := filepath.Join(stagingDir, "debtik")
  406. if info, err := os.Stat(stagedDebtik); err == nil && info.IsDir() {
  407. // Clean and replace /etc/debtik contents
  408. if err := m.syncDirectory(stagedDebtik, m.debtikDir); err != nil {
  409. return nil, fmt.Errorf("failed restoring debtik configuration: %w", err)
  410. }
  411. }
  412. // 5. Apply Network directory (/etc/network/interfaces.d)
  413. stagedNetwork := filepath.Join(stagingDir, "network")
  414. if info, err := os.Stat(stagedNetwork); err == nil && info.IsDir() {
  415. if err := os.MkdirAll(m.networkDir, 0755); err != nil {
  416. return nil, fmt.Errorf("failed ensuring network config dir: %w", err)
  417. }
  418. entries, _ := os.ReadDir(stagedNetwork)
  419. for _, entry := range entries {
  420. if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".cfg") {
  421. src := filepath.Join(stagedNetwork, entry.Name())
  422. dst := filepath.Join(m.networkDir, entry.Name())
  423. data, err := os.ReadFile(src)
  424. if err == nil {
  425. _ = os.WriteFile(dst, data, 0644)
  426. }
  427. }
  428. }
  429. }
  430. result := &RestoreResult{
  431. Success: true,
  432. Message: "Configuration restored successfully. Reloading appliance controller...",
  433. Manifest: manifest,
  434. SafetyBackup: safetyBackupPath,
  435. RestoredAt: time.Now().UTC(),
  436. }
  437. return result, nil
  438. }
  439. // syncDirectory copies srcDir into dstDir, cleaning obsolete files while preserving security permissions.
  440. func (m *Manager) syncDirectory(srcDir, dstDir string) error {
  441. if err := os.MkdirAll(dstDir, 0755); err != nil {
  442. return err
  443. }
  444. // Wipe existing files in dstDir to avoid stale or orphaned configs
  445. existing, err := os.ReadDir(dstDir)
  446. if err == nil {
  447. for _, e := range existing {
  448. _ = os.RemoveAll(filepath.Join(dstDir, e.Name()))
  449. }
  450. }
  451. return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
  452. if err != nil {
  453. return err
  454. }
  455. rel, err := filepath.Rel(srcDir, path)
  456. if err != nil {
  457. return err
  458. }
  459. target := filepath.Join(dstDir, rel)
  460. if info.IsDir() {
  461. dirPerm := os.FileMode(0755)
  462. if strings.Contains(rel, "certs") {
  463. dirPerm = 0700
  464. }
  465. return os.MkdirAll(target, dirPerm)
  466. }
  467. data, err := os.ReadFile(path)
  468. if err != nil {
  469. return err
  470. }
  471. mode := info.Mode().Perm()
  472. lower := strings.ToLower(rel)
  473. if strings.Contains(lower, "key") || strings.Contains(lower, "users.json") || strings.Contains(lower, "ppp_secrets.json") {
  474. mode = 0600
  475. } else if mode == 0 {
  476. mode = 0644
  477. }
  478. return os.WriteFile(target, data, mode)
  479. })
  480. }
  481. // TriggerRestart initiates an asynchronous service reload or restart.
  482. func (m *Manager) TriggerRestart(delay time.Duration) {
  483. go func() {
  484. time.Sleep(delay)
  485. log.Printf("[Backup] Initiating controller restart following configuration restore...")
  486. if m.restartFunc != nil {
  487. if err := m.restartFunc(); err != nil {
  488. log.Printf("[Backup] Restart hook returned error: %v", err)
  489. }
  490. return
  491. }
  492. cmd := exec.Command("systemctl", "restart", "debtik")
  493. if err := cmd.Run(); err != nil {
  494. log.Printf("[Backup] systemctl restart debtik failed: %v", err)
  495. }
  496. }()
  497. }
  498. // ListLocalBackups returns a list of stored backups in the backupStore.
  499. func (m *Manager) ListLocalBackups() ([]BackupInfo, error) {
  500. if err := os.MkdirAll(m.backupStore, 0700); err != nil {
  501. return nil, err
  502. }
  503. entries, err := os.ReadDir(m.backupStore)
  504. if err != nil {
  505. return nil, err
  506. }
  507. var results []BackupInfo
  508. for _, e := range entries {
  509. if e.IsDir() || !strings.HasSuffix(e.Name(), ".zip") {
  510. continue
  511. }
  512. info, err := e.Info()
  513. if err != nil {
  514. continue
  515. }
  516. filePath := filepath.Join(m.backupStore, e.Name())
  517. bInfo := BackupInfo{
  518. Filename: e.Name(),
  519. Size: info.Size(),
  520. ModTime: info.ModTime().UTC(),
  521. }
  522. // Attempt reading manifest for richer metadata
  523. if f, err := os.Open(filePath); err == nil {
  524. if zr, err := zip.NewReader(f, info.Size()); err == nil {
  525. for _, zf := range zr.File {
  526. if zf.Name == "manifest.json" {
  527. if rc, err := zf.Open(); err == nil {
  528. var mf Manifest
  529. if err := json.NewDecoder(rc).Decode(&mf); err == nil {
  530. bInfo.DebTikVersion = mf.DebTikVersion
  531. bInfo.Hostname = mf.Hostname
  532. bInfo.Comment = mf.Comment
  533. }
  534. _ = rc.Close()
  535. }
  536. break
  537. }
  538. }
  539. }
  540. _ = f.Close()
  541. }
  542. results = append(results, bInfo)
  543. }
  544. sort.Slice(results, func(i, j int) bool {
  545. return results[i].ModTime.After(results[j].ModTime)
  546. })
  547. return results, nil
  548. }
  549. // GetLocalBackup returns the full path of a stored backup file after sanitization.
  550. func (m *Manager) GetLocalBackup(filename string) (string, error) {
  551. cleanName := sanitizeFilename(filename)
  552. if !strings.HasSuffix(cleanName, ".zip") {
  553. return "", errors.New("backup filename must have .zip extension")
  554. }
  555. fullPath := filepath.Join(m.backupStore, cleanName)
  556. if info, err := os.Stat(fullPath); err != nil || info.IsDir() {
  557. return "", fmt.Errorf("backup file %q not found", cleanName)
  558. }
  559. return fullPath, nil
  560. }
  561. // sanitizeFilename restricts filenames to safe alphanumeric characters, dashes, underscores, and dots.
  562. func sanitizeFilename(name string) string {
  563. name = filepath.Base(name)
  564. var b strings.Builder
  565. for _, r := range name {
  566. if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
  567. b.WriteRune(r)
  568. } else {
  569. b.WriteRune('_')
  570. }
  571. }
  572. res := b.String()
  573. if res == "" || res == "." || res == ".." {
  574. return "backup.zip"
  575. }
  576. return res
  577. }