19 "github.com/mjl-/bstore"
21 "github.com/mjl-/mox/dmarcdb"
22 "github.com/mjl-/mox/mox-"
23 "github.com/mjl-/mox/moxvar"
24 "github.com/mjl-/mox/mtastsdb"
25 "github.com/mjl-/mox/queue"
26 "github.com/mjl-/mox/store"
27 "github.com/mjl-/mox/tlsrptdb"
30func xbackupctl(ctx context.Context, xctl *ctl) {
39 // Convention in this function: variables containing "src" or "dst" are file system
40 // paths that can be passed to os.Open and such. Variables with dirs/paths without
41 // "src" or "dst" are incomplete paths relative to the source or destination data
44 dstDir := xctl.xread()
45 verbose := xctl.xread() == "verbose"
47 // Set when an error is encountered. At the end, we warn if set.
50 // We'll be writing output, and logging both to mox and the ctl stream.
51 xwriter := xctl.writer()
53 // Format easily readable output for the user.
54 formatLog := func(prefix, text string, err error, attrs ...slog.Attr) []byte {
56 fmt.Fprint(&b, prefix)
59 fmt.Fprint(&b, ": "+err.Error())
61 for _, a := range attrs {
62 fmt.Fprintf(&b, "; %s=%v", a.Key, a.Value)
68 // Log an error to both the mox service as the user running "mox backup".
69 pkglogx := func(prefix, text string, err error, attrs ...slog.Attr) {
70 xctl.log.Errorx(text, err, attrs...)
71 xwriter.Write(formatLog(prefix, text, err, attrs...))
74 // Log an error but don't mark backup as failed.
75 xwarnx := func(text string, err error, attrs ...slog.Attr) {
76 pkglogx("warning: ", text, err, attrs...)
79 // Log an error that causes the backup to be marked as failed. We typically
80 // continue processing though.
81 xerrx := func(text string, err error, attrs ...slog.Attr) {
83 pkglogx("error: ", text, err, attrs...)
86 // If verbose is enabled, log to the cli command. Always log as info level.
87 xvlog := func(text string, attrs ...slog.Attr) {
88 xctl.log.Info(text, attrs...)
90 xwriter.Write(formatLog("", text, nil, attrs...))
94 dstConfigDir := filepath.Join(dstDir, "config")
95 dstDataDir := filepath.Join(dstDir, "data")
97 // Warn if directories already exist, will likely cause failures when trying to
98 // write files that already exist.
99 if _, err := os.Stat(dstConfigDir); err == nil {
100 xwarnx("destination config directory already exists", nil, slog.String("configdir", dstConfigDir))
102 if _, err := os.Stat(dstDataDir); err == nil {
103 xwarnx("destination data directory already exists", nil, slog.String("datadir", dstDataDir))
106 os.MkdirAll(dstDir, 0770)
107 os.MkdirAll(dstConfigDir, 0770)
108 os.MkdirAll(dstDataDir, 0770)
110 // Copy all files in the config dir.
111 srcConfigDir := filepath.Clean(mox.ConfigDirPath("."))
112 err := filepath.WalkDir(srcConfigDir, func(srcPath string, d fs.DirEntry, err error) error {
117 if srcConfigDir == srcPath {
121 // Trim directory and separator.
122 relPath := srcPath[len(srcConfigDir)+1:]
124 destPath := filepath.Join(dstConfigDir, relPath)
127 if info, err := os.Stat(srcPath); err != nil {
128 return fmt.Errorf("stat config dir %s: %v", srcPath, err)
129 } else if err := os.Mkdir(destPath, info.Mode()&0777); err != nil {
130 return fmt.Errorf("mkdir %s: %v", destPath, err)
134 if d.Type()&fs.ModeSymlink != 0 {
135 linkDest, err := os.Readlink(srcPath)
137 return fmt.Errorf("reading symlink %s: %v", srcPath, err)
139 if err := os.Symlink(linkDest, destPath); err != nil {
140 return fmt.Errorf("creating symlink %s: %v", destPath, err)
144 if !d.Type().IsRegular() {
145 xwarnx("skipping non-regular/dir/symlink file in config dir", nil, slog.String("path", srcPath))
149 sf, err := os.Open(srcPath)
151 return fmt.Errorf("open config file %s: %v", srcPath, err)
153 info, err := sf.Stat()
155 return fmt.Errorf("stat config file %s: %v", srcPath, err)
157 df, err := os.OpenFile(destPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0777&info.Mode())
159 return fmt.Errorf("create destination config file %s: %v", destPath, err)
164 xctl.log.Check(err, "closing file")
169 xctl.log.Check(err, "closing file")
171 if _, err := io.Copy(df, sf); err != nil {
172 return fmt.Errorf("copying config file %s to %s: %v", srcPath, destPath, err)
174 if err := df.Close(); err != nil {
175 return fmt.Errorf("closing destination config file %s: %v", srcPath, err)
181 xerrx("storing config directory", err)
184 srcDataDir := filepath.Clean(mox.DataDirPath("."))
186 // When creating a file in the destination, we first ensure its directory exists.
187 // We track which directories we created, to prevent needless syscalls.
188 createdDirs := map[string]struct{}{}
189 ensureDestDir := func(dstpath string) {
190 dstdir := filepath.Dir(dstpath)
191 if _, ok := createdDirs[dstdir]; !ok {
192 err := os.MkdirAll(dstdir, 0770)
194 xerrx("creating directory", err)
196 createdDirs[dstdir] = struct{}{}
200 // Backup a single file by copying (never hardlinking, the file may change).
201 backupFile := func(path string) {
203 srcpath := filepath.Join(srcDataDir, path)
204 dstpath := filepath.Join(dstDataDir, path)
206 sf, err := os.Open(srcpath)
208 xerrx("open source file (not backed up)", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
213 xctl.log.Check(err, "closing source file")
216 ensureDestDir(dstpath)
217 df, err := os.OpenFile(dstpath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
219 xerrx("creating destination file (not backed up)", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
225 xctl.log.Check(err, "closing destination file")
228 if _, err := io.Copy(df, sf); err != nil {
229 xerrx("copying file (not backed up properly)", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
235 xerrx("closing destination file (not backed up properly)", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
238 xvlog("backed up file", slog.String("path", path), slog.Duration("duration", time.Since(tmFile)))
241 // Back up the files in a directory (by copying).
242 backupDir := func(dir string) {
244 srcdir := filepath.Join(srcDataDir, dir)
245 dstdir := filepath.Join(dstDataDir, dir)
246 err := filepath.WalkDir(srcdir, func(srcpath string, d fs.DirEntry, err error) error {
248 xerrx("walking file (not backed up)", err, slog.String("srcpath", srcpath))
254 backupFile(srcpath[len(srcDataDir)+1:])
258 xerrx("copying directory (not backed up properly)", err,
259 slog.String("srcdir", srcdir),
260 slog.String("dstdir", dstdir),
261 slog.Duration("duration", time.Since(tmDir)))
264 xvlog("backed up directory", slog.String("dir", dir), slog.Duration("duration", time.Since(tmDir)))
267 // Backup a database by copying it in a readonly transaction. Wrapped by backupDB
268 // which logs and returns just a bool.
269 backupDB0 := func(db *bstore.DB, path string) error {
270 dstpath := filepath.Join(dstDataDir, path)
271 ensureDestDir(dstpath)
272 df, err := os.OpenFile(dstpath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
274 return fmt.Errorf("creating destination file: %v", err)
279 xctl.log.Check(err, "closing destination database file")
282 err = db.Read(ctx, func(tx *bstore.Tx) error {
283 // Using regular WriteTo seems fine, and fast. It just copies pages.
285 // bolt.Compact is slower, it writes all key/value pairs, building up new data
286 // structures. My compacted test database was ~60% of original size. Lz4 on the
287 // uncompacted database got it to 14%. Lz4 on the compacted database got it to 13%.
288 // Backups are likely archived somewhere with compression, so we don't compact.
290 // Tests with WriteTo and os.O_DIRECT were slower than without O_DIRECT, but
291 // probably because everything fit in the page cache. It may be better to use
292 // O_DIRECT when copying many large or inactive databases.
293 _, err := tx.WriteTo(df)
297 return fmt.Errorf("copying database: %v", err)
302 return fmt.Errorf("closing destination database after copy: %v", err)
307 backupDB := func(db *bstore.DB, path string) bool {
309 err := backupDB0(db, path)
311 xerrx("backing up database", err, slog.String("path", path), slog.Duration("duration", time.Since(start)))
314 xvlog("backed up database file", slog.String("path", path), slog.Duration("duration", time.Since(start)))
318 // Try to create a hardlink. Fall back to copying the file (e.g. when on different file system).
319 warnedHardlink := false // We warn once about failing to hardlink.
320 linkOrCopy := func(srcpath, dstpath string) (bool, error) {
321 ensureDestDir(dstpath)
323 if err := os.Link(srcpath, dstpath); err == nil {
325 } else if os.IsNotExist(err) {
326 // No point in trying with regular copy, we would warn twice.
328 } else if !warnedHardlink {
329 var hardlinkHint string
330 if runtime.GOOS == "linux" && errors.Is(err, syscall.EXDEV) {
331 hardlinkHint = " (hint: if running under systemd, ReadWritePaths in mox.service may cause multiple mountpoints; consider merging paths into a single parent directory to prevent cross-device/mountpoint hardlinks)"
333 xwarnx("creating hardlink to message failed, will be doing regular file copies and not warn again"+hardlinkHint, err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
334 warnedHardlink = true
337 // Fall back to copying.
338 sf, err := os.Open(srcpath)
340 return false, fmt.Errorf("open source path %s: %v", srcpath, err)
344 xctl.log.Check(err, "closing copied source file")
347 df, err := os.OpenFile(dstpath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
349 return false, fmt.Errorf("create destination path %s: %v", dstpath, err)
354 xctl.log.Check(err, "closing partial destination file")
357 if _, err := io.Copy(df, sf); err != nil {
358 return false, fmt.Errorf("coping: %v", err)
363 return false, fmt.Errorf("closing destination file: %v", err)
368 // Start making the backup.
369 tmStart := time.Now()
371 xctl.log.Print("making backup", slog.String("destdir", dstDataDir))
373 if err := os.MkdirAll(dstDataDir, 0770); err != nil {
374 xerrx("creating destination data directory", err)
377 if err := os.WriteFile(filepath.Join(dstDataDir, "moxversion"), []byte(moxvar.Version), 0660); err != nil {
378 xerrx("writing moxversion", err)
380 backupDB(store.AuthDB, "auth.db")
381 backupDB(dmarcdb.ReportsDB, "dmarcrpt.db")
382 backupDB(dmarcdb.EvalDB, "dmarceval.db")
383 backupDB(mtastsdb.DB, "mtasts.db")
384 backupDB(tlsrptdb.ReportDB, "tlsrpt.db")
385 backupDB(tlsrptdb.ResultDB, "tlsrptresult.db")
386 backupFile("receivedid.key")
388 // Acme directory is optional.
389 srcAcmeDir := filepath.Join(srcDataDir, "acme")
390 if _, err := os.Stat(srcAcmeDir); err == nil {
392 } else if err != nil && !os.IsNotExist(err) {
393 xerrx("copying acme/", err)
396 // Copy the queue database and all message files.
397 backupQueue := func(path string) {
398 tmQueue := time.Now()
400 if !backupDB(queue.DB, path) {
404 dstdbpath := filepath.Join(dstDataDir, path)
405 opts := bstore.Options{MustExist: true, RegisterLogger: xctl.log.Logger}
406 db, err := bstore.Open(ctx, dstdbpath, &opts, queue.DBTypes...)
408 xerrx("open copied queue database", err, slog.String("dstpath", dstdbpath), slog.Duration("duration", time.Since(tmQueue)))
415 xctl.log.Check(err, "closing new queue db")
419 // Link/copy known message files. If a message has been removed while we read the
420 // database, our backup is not consistent and the backup will be marked failed.
422 seen := map[string]struct{}{}
423 var nlinked, ncopied int
425 err = bstore.QueryDB[queue.Msg](ctx, db).ForEach(func(m queue.Msg) error {
429 mp := store.MessagePath(m.ID)
430 seen[mp] = struct{}{}
431 srcpath := filepath.Join(srcDataDir, "queue", mp)
432 dstpath := filepath.Join(dstDataDir, "queue", mp)
433 if linked, err := linkOrCopy(srcpath, dstpath); err != nil {
434 xerrx("linking/copying queue message", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
443 xerrx("processing queue messages (not backed up properly)", err, slog.Duration("duration", time.Since(tmMsgs)))
445 xvlog("queue message files linked/copied",
446 slog.Int("linked", nlinked),
447 slog.Int("copied", ncopied),
448 slog.Duration("duration", time.Since(tmMsgs)))
451 // Read through all files in queue directory and warn about anything we haven't
452 // handled yet. Message files that are newer than we expect from our consistent
453 // database snapshot are ignored.
455 srcqdir := filepath.Join(srcDataDir, "queue")
456 err = filepath.WalkDir(srcqdir, func(srcqpath string, d fs.DirEntry, err error) error {
458 xerrx("walking files in queue", err, slog.String("srcpath", srcqpath))
464 p := srcqpath[len(srcqdir)+1:]
465 if _, ok := seen[p]; ok {
471 // Skip any messages that were added since we started on our consistent snapshot.
472 // We don't want to cause spurious backup warnings.
473 if id, err := strconv.ParseInt(filepath.Base(p), 10, 64); err == nil && maxID > 0 && id > maxID && p == store.MessagePath(id) {
477 qp := filepath.Join("queue", p)
478 xwarnx("backing up unrecognized file in queue directory", nil, slog.String("path", qp))
483 xerrx("walking queue directory (not backed up properly)", err, slog.String("dir", "queue"), slog.Duration("duration", time.Since(tmWalk)))
485 xvlog("walked queue directory", slog.Duration("duration", time.Since(tmWalk)))
488 xvlog("queue backed finished", slog.Duration("duration", time.Since(tmQueue)))
490 backupQueue(filepath.FromSlash("queue/index.db"))
492 backupAccount := func(acc *store.Account) {
495 xctl.log.Check(err, "closing account")
498 tmAccount := time.Now()
500 // Copy database file.
501 dbpath := filepath.Join("accounts", acc.Name, "index.db")
502 backupDB(acc.DB, dbpath)
504 // todo: should document/check not taking a rlock on account.
506 // Copy junkfilter files, if configured.
507 if jf, _, err := acc.OpenJunkFilter(ctx, xctl.log); err != nil {
508 if !errors.Is(err, store.ErrNoJunkFilter) {
509 xerrx("opening junk filter for account (not backed up)", err)
513 jfpath := filepath.Join("accounts", acc.Name, "junkfilter.db")
515 bloompath := filepath.Join("accounts", acc.Name, "junkfilter.bloom")
516 backupFile(bloompath)
518 xctl.log.Check(err, "closing junkfilter")
521 dstdbpath := filepath.Join(dstDataDir, dbpath)
522 opts := bstore.Options{MustExist: true, RegisterLogger: xctl.log.Logger}
523 db, err := bstore.Open(ctx, dstdbpath, &opts, store.DBTypes...)
525 xerrx("open copied account database", err, slog.String("dstpath", dstdbpath), slog.Duration("duration", time.Since(tmAccount)))
532 xctl.log.Check(err, "close account database")
536 // Link/copy known message files.
538 seen := map[string]struct{}{}
540 var nlinked, ncopied int
541 err = bstore.QueryDB[store.Message](ctx, db).FilterEqual("Expunged", false).ForEach(func(m store.Message) error {
545 mp := store.MessagePath(m.ID)
546 seen[mp] = struct{}{}
547 amp := filepath.Join("accounts", acc.Name, "msg", mp)
548 srcpath := filepath.Join(srcDataDir, amp)
549 dstpath := filepath.Join(dstDataDir, amp)
550 if linked, err := linkOrCopy(srcpath, dstpath); err != nil {
551 xerrx("linking/copying account message", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
560 xerrx("processing account messages (not backed up properly)", err, slog.Duration("duration", time.Since(tmMsgs)))
562 xvlog("account message files linked/copied",
563 slog.Int("linked", nlinked),
564 slog.Int("copied", ncopied),
565 slog.Duration("duration", time.Since(tmMsgs)))
568 eraseIDs := map[int64]struct{}{}
569 err = bstore.QueryDB[store.MessageErase](ctx, db).ForEach(func(me store.MessageErase) error {
570 eraseIDs[me.ID] = struct{}{}
574 xerrx("listing erased messages", err)
577 // Read through all files in queue directory and warn about anything we haven't
578 // handled yet. Message files that are newer than we expect from our consistent
579 // database snapshot are ignored.
581 srcadir := filepath.Join(srcDataDir, "accounts", acc.Name)
582 err = filepath.WalkDir(srcadir, func(srcapath string, d fs.DirEntry, err error) error {
584 xerrx("walking files in account", err, slog.String("srcpath", srcapath))
590 p := srcapath[len(srcadir)+1:]
591 l := strings.Split(p, string(filepath.Separator))
593 mp := filepath.Join(l[1:]...)
594 if _, ok := seen[mp]; ok {
598 // Skip any messages that were added since we started on our consistent snapshot,
599 // or messages that will be erased. We don't want to cause spurious backup
601 id, err := strconv.ParseInt(l[len(l)-1], 10, 64)
602 if err == nil && id > maxID && mp == store.MessagePath(id) {
604 } else if _, ok := eraseIDs[id]; err == nil && ok {
609 case "index.db", "junkfilter.db", "junkfilter.bloom":
612 ap := filepath.Join("accounts", acc.Name, p)
613 if strings.HasPrefix(p, "msg"+string(filepath.Separator)) {
614 xwarnx("backing up unrecognized file in account message directory (should be moved away)", nil, slog.String("path", ap))
616 xwarnx("backing up unrecognized file in account directory", nil, slog.String("path", ap))
622 xerrx("walking account directory (not backed up properly)", err, slog.String("srcdir", srcadir), slog.Duration("duration", time.Since(tmWalk)))
624 xvlog("walked account directory", slog.Duration("duration", time.Since(tmWalk)))
627 xvlog("account backup finished", slog.String("dir", filepath.Join("accounts", acc.Name)), slog.Duration("duration", time.Since(tmAccount)))
630 // For each configured account, open it, make a copy of the database and
631 // hardlink/copy the messages. We track the accounts we handled, and skip the
632 // account directories when handling "all other files" below.
633 accounts := map[string]struct{}{}
634 for _, accName := range mox.Conf.Accounts() {
635 acc, err := store.OpenAccount(xctl.log, accName, false)
637 xerrx("opening account for copying (will try to copy as regular files later)", err, slog.String("account", accName))
640 accounts[accName] = struct{}{}
644 // Copy all other files, that aren't part of the known files, databases, queue or accounts.
646 err = filepath.WalkDir(srcDataDir, func(srcpath string, d fs.DirEntry, err error) error {
648 xerrx("walking path", err, slog.String("path", srcpath))
652 if srcpath == srcDataDir {
655 p := srcpath[len(srcDataDir)+1:]
656 if p == "queue" || p == "acme" || p == "tmp" {
659 l := strings.Split(p, string(filepath.Separator))
660 if len(l) >= 2 && l[0] == "accounts" {
662 if _, ok := accounts[name]; ok {
667 // Only files are explicitly backed up.
673 case "auth.db", "dmarcrpt.db", "dmarceval.db", "mtasts.db", "tlsrpt.db", "tlsrptresult.db", "receivedid.key", "ctl":
676 case "lastknownversion": // Optional file, not yet handled.
678 xwarnx("backing up unrecognized file", nil, slog.String("path", p))
684 xerrx("walking other files (not backed up properly)", err, slog.Duration("duration", time.Since(tmWalk)))
686 xvlog("walking other files finished", slog.Duration("duration", time.Since(tmWalk)))
689 xvlog("backup finished", slog.Duration("duration", time.Since(tmStart)))
694 xctl.xwrite("errors were encountered during backup")