18 "github.com/mjl-/bstore"
20 "github.com/mjl-/mox/dmarcdb"
21 "github.com/mjl-/mox/mox-"
22 "github.com/mjl-/mox/moxvar"
23 "github.com/mjl-/mox/mtastsdb"
24 "github.com/mjl-/mox/queue"
25 "github.com/mjl-/mox/store"
26 "github.com/mjl-/mox/tlsrptdb"
29func backupctl(ctx context.Context, ctl *ctl) {
38 // Convention in this function: variables containing "src" or "dst" are file system
39 // paths that can be passed to os.Open and such. Variables with dirs/paths without
40 // "src" or "dst" are incomplete paths relative to the source or destination data
43 dstDataDir := ctl.xread()
44 verbose := ctl.xread() == "verbose"
46 // Set when an error is encountered. At the end, we warn if set.
49 // We'll be writing output, and logging both to mox and the ctl stream.
50 writer := ctl.writer()
52 // Format easily readable output for the user.
53 formatLog := func(prefix, text string, err error, attrs ...slog.Attr) []byte {
55 fmt.Fprint(&b, prefix)
58 fmt.Fprint(&b, ": "+err.Error())
60 for _, a := range attrs {
61 fmt.Fprintf(&b, "; %s=%v", a.Key, a.Value)
67 // Log an error to both the mox service as the user running "mox backup".
68 pkglogx := func(prefix, text string, err error, attrs ...slog.Attr) {
69 ctl.log.Errorx(text, err, attrs...)
71 _, werr := writer.Write(formatLog(prefix, text, err, attrs...))
72 ctl.xcheck(werr, "write to ctl")
75 // Log an error but don't mark backup as failed.
76 xwarnx := func(text string, err error, attrs ...slog.Attr) {
77 pkglogx("warning: ", text, err, attrs...)
80 // Log an error that causes the backup to be marked as failed. We typically
81 // continue processing though.
82 xerrx := func(text string, err error, attrs ...slog.Attr) {
84 pkglogx("error: ", text, err, attrs...)
87 // If verbose is enabled, log to the cli command. Always log as info level.
88 xvlog := func(text string, attrs ...slog.Attr) {
89 ctl.log.Info(text, attrs...)
91 _, werr := writer.Write(formatLog("", text, nil, attrs...))
92 ctl.xcheck(werr, "write to ctl")
96 if _, err := os.Stat(dstDataDir); err == nil {
97 xwarnx("destination data directory already exists", nil, slog.String("dir", dstDataDir))
100 srcDataDir := filepath.Clean(mox.DataDirPath("."))
102 // When creating a file in the destination, we first ensure its directory exists.
103 // We track which directories we created, to prevent needless syscalls.
104 createdDirs := map[string]struct{}{}
105 ensureDestDir := func(dstpath string) {
106 dstdir := filepath.Dir(dstpath)
107 if _, ok := createdDirs[dstdir]; !ok {
108 err := os.MkdirAll(dstdir, 0770)
110 xerrx("creating directory", err)
112 createdDirs[dstdir] = struct{}{}
116 // Backup a single file by copying (never hardlinking, the file may change).
117 backupFile := func(path string) {
119 srcpath := filepath.Join(srcDataDir, path)
120 dstpath := filepath.Join(dstDataDir, path)
122 sf, err := os.Open(srcpath)
124 xerrx("open source file (not backed up)", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
129 ensureDestDir(dstpath)
130 df, err := os.OpenFile(dstpath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
132 xerrx("creating destination file (not backed up)", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
140 if _, err := io.Copy(df, sf); err != nil {
141 xerrx("copying file (not backed up properly)", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
147 xerrx("closing destination file (not backed up properly)", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
150 xvlog("backed up file", slog.String("path", path), slog.Duration("duration", time.Since(tmFile)))
153 // Back up the files in a directory (by copying).
154 backupDir := func(dir string) {
156 srcdir := filepath.Join(srcDataDir, dir)
157 dstdir := filepath.Join(dstDataDir, dir)
158 err := filepath.WalkDir(srcdir, func(srcpath string, d fs.DirEntry, err error) error {
160 xerrx("walking file (not backed up)", err, slog.String("srcpath", srcpath))
166 backupFile(srcpath[len(srcDataDir)+1:])
170 xerrx("copying directory (not backed up properly)", err,
171 slog.String("srcdir", srcdir),
172 slog.String("dstdir", dstdir),
173 slog.Duration("duration", time.Since(tmDir)))
176 xvlog("backed up directory", slog.String("dir", dir), slog.Duration("duration", time.Since(tmDir)))
179 // Backup a database by copying it in a readonly transaction.
180 // Always logs on error, so caller doesn't have to, but also returns the error so
181 // callers can see result.
182 backupDB := func(db *bstore.DB, path string) (rerr error) {
185 xerrx("backing up database", rerr, slog.String("path", path))
191 dstpath := filepath.Join(dstDataDir, path)
192 ensureDestDir(dstpath)
193 df, err := os.OpenFile(dstpath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
195 return fmt.Errorf("creating destination file: %v", err)
202 err = db.Read(ctx, func(tx *bstore.Tx) error {
203 // Using regular WriteTo seems fine, and fast. It just copies pages.
205 // bolt.Compact is slower, it writes all key/value pairs, building up new data
206 // structures. My compacted test database was ~60% of original size. Lz4 on the
207 // uncompacted database got it to 14%. Lz4 on the compacted database got it to 13%.
208 // Backups are likely archived somewhere with compression, so we don't compact.
210 // Tests with WriteTo and os.O_DIRECT were slower than without O_DIRECT, but
211 // probably because everything fit in the page cache. It may be better to use
212 // O_DIRECT when copying many large or inactive databases.
213 _, err := tx.WriteTo(df)
217 return fmt.Errorf("copying database: %v", err)
222 return fmt.Errorf("closing destination database after copy: %v", err)
224 xvlog("backed up database file", slog.String("path", path), slog.Duration("duration", time.Since(tmDB)))
228 // Try to create a hardlink. Fall back to copying the file (e.g. when on different file system).
229 warnedHardlink := false // We warn once about failing to hardlink.
230 linkOrCopy := func(srcpath, dstpath string) (bool, error) {
231 ensureDestDir(dstpath)
233 if err := os.Link(srcpath, dstpath); err == nil {
235 } else if os.IsNotExist(err) {
236 // No point in trying with regular copy, we would warn twice.
238 } else if !warnedHardlink {
239 var hardlinkHint string
240 if runtime.GOOS == "linux" && errors.Is(err, syscall.EXDEV) {
241 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)"
243 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))
244 warnedHardlink = true
247 // Fall back to copying.
248 sf, err := os.Open(srcpath)
250 return false, fmt.Errorf("open source path %s: %v", srcpath, err)
254 ctl.log.Check(err, "closing copied source file")
257 df, err := os.OpenFile(dstpath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
259 return false, fmt.Errorf("create destination path %s: %v", dstpath, err)
264 ctl.log.Check(err, "closing partial destination file")
267 if _, err := io.Copy(df, sf); err != nil {
268 return false, fmt.Errorf("coping: %v", err)
273 return false, fmt.Errorf("closing destination file: %v", err)
278 // Start making the backup.
279 tmStart := time.Now()
281 ctl.log.Print("making backup", slog.String("destdir", dstDataDir))
283 err := os.MkdirAll(dstDataDir, 0770)
285 xerrx("creating destination data directory", err)
288 if err := os.WriteFile(filepath.Join(dstDataDir, "moxversion"), []byte(moxvar.Version), 0660); err != nil {
289 xerrx("writing moxversion", err)
291 backupDB(dmarcdb.ReportsDB, "dmarcrpt.db")
292 backupDB(dmarcdb.EvalDB, "dmarceval.db")
293 backupDB(mtastsdb.DB, "mtasts.db")
294 backupDB(tlsrptdb.ReportDB, "tlsrpt.db")
295 backupDB(tlsrptdb.ResultDB, "tlsrptresult.db")
296 backupFile("receivedid.key")
298 // Acme directory is optional.
299 srcAcmeDir := filepath.Join(srcDataDir, "acme")
300 if _, err := os.Stat(srcAcmeDir); err == nil {
302 } else if err != nil && !os.IsNotExist(err) {
303 xerrx("copying acme/", err)
306 // Copy the queue database and all message files.
307 backupQueue := func(path string) {
308 tmQueue := time.Now()
310 if err := backupDB(queue.DB, path); err != nil {
311 xerrx("queue not backed up", err, slog.String("path", path), slog.Duration("duration", time.Since(tmQueue)))
315 dstdbpath := filepath.Join(dstDataDir, path)
316 opts := bstore.Options{MustExist: true, RegisterLogger: ctl.log.Logger}
317 db, err := bstore.Open(ctx, dstdbpath, &opts, queue.DBTypes...)
319 xerrx("open copied queue database", err, slog.String("dstpath", dstdbpath), slog.Duration("duration", time.Since(tmQueue)))
326 ctl.log.Check(err, "closing new queue db")
330 // Link/copy known message files. Warn if files are missing or unexpected
331 // (though a message file could have been removed just now due to delivery, or a
332 // new message may have been queued).
334 seen := map[string]struct{}{}
335 var nlinked, ncopied int
336 err = bstore.QueryDB[queue.Msg](ctx, db).ForEach(func(m queue.Msg) error {
337 mp := store.MessagePath(m.ID)
338 seen[mp] = struct{}{}
339 srcpath := filepath.Join(srcDataDir, "queue", mp)
340 dstpath := filepath.Join(dstDataDir, "queue", mp)
341 if linked, err := linkOrCopy(srcpath, dstpath); err != nil {
342 xerrx("linking/copying queue message", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
351 xerrx("processing queue messages (not backed up properly)", err, slog.Duration("duration", time.Since(tmMsgs)))
353 xvlog("queue message files linked/copied",
354 slog.Int("linked", nlinked),
355 slog.Int("copied", ncopied),
356 slog.Duration("duration", time.Since(tmMsgs)))
359 // Read through all files in queue directory and warn about anything we haven't handled yet.
361 srcqdir := filepath.Join(srcDataDir, "queue")
362 err = filepath.WalkDir(srcqdir, func(srcqpath string, d fs.DirEntry, err error) error {
364 xerrx("walking files in queue", err, slog.String("srcpath", srcqpath))
370 p := srcqpath[len(srcqdir)+1:]
371 if _, ok := seen[p]; ok {
377 qp := filepath.Join("queue", p)
378 xwarnx("backing up unrecognized file in queue directory", nil, slog.String("path", qp))
383 xerrx("walking queue directory (not backed up properly)", err, slog.String("dir", "queue"), slog.Duration("duration", time.Since(tmWalk)))
385 xvlog("walked queue directory", slog.Duration("duration", time.Since(tmWalk)))
388 xvlog("queue backed finished", slog.Duration("duration", time.Since(tmQueue)))
390 backupQueue(filepath.FromSlash("queue/index.db"))
392 backupAccount := func(acc *store.Account) {
395 tmAccount := time.Now()
397 // Copy database file.
398 dbpath := filepath.Join("accounts", acc.Name, "index.db")
399 err := backupDB(acc.DB, dbpath)
401 xerrx("copying account database", err, slog.String("path", dbpath), slog.Duration("duration", time.Since(tmAccount)))
404 // todo: should document/check not taking a rlock on account.
406 // Copy junkfilter files, if configured.
407 if jf, _, err := acc.OpenJunkFilter(ctx, ctl.log); err != nil {
408 if !errors.Is(err, store.ErrNoJunkFilter) {
409 xerrx("opening junk filter for account (not backed up)", err)
413 jfpath := filepath.Join("accounts", acc.Name, "junkfilter.db")
415 bloompath := filepath.Join("accounts", acc.Name, "junkfilter.bloom")
416 backupFile(bloompath)
419 ctl.log.Check(err, "closing junkfilter")
422 dstdbpath := filepath.Join(dstDataDir, dbpath)
423 opts := bstore.Options{MustExist: true, RegisterLogger: ctl.log.Logger}
424 db, err := bstore.Open(ctx, dstdbpath, &opts, store.DBTypes...)
426 xerrx("open copied account database", err, slog.String("dstpath", dstdbpath), slog.Duration("duration", time.Since(tmAccount)))
433 ctl.log.Check(err, "close account database")
437 // Link/copy known message files. Warn if files are missing or unexpected (though a
438 // message file could have been added just now due to delivery, or a message have
441 seen := map[string]struct{}{}
442 var nlinked, ncopied int
443 err = bstore.QueryDB[store.Message](ctx, db).FilterEqual("Expunged", false).ForEach(func(m store.Message) error {
444 mp := store.MessagePath(m.ID)
445 seen[mp] = struct{}{}
446 amp := filepath.Join("accounts", acc.Name, "msg", mp)
447 srcpath := filepath.Join(srcDataDir, amp)
448 dstpath := filepath.Join(dstDataDir, amp)
449 if linked, err := linkOrCopy(srcpath, dstpath); err != nil {
450 xerrx("linking/copying account message", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
459 xerrx("processing account messages (not backed up properly)", err, slog.Duration("duration", time.Since(tmMsgs)))
461 xvlog("account message files linked/copied",
462 slog.Int("linked", nlinked),
463 slog.Int("copied", ncopied),
464 slog.Duration("duration", time.Since(tmMsgs)))
467 // Read through all files in account directory and warn about anything we haven't handled yet.
469 srcadir := filepath.Join(srcDataDir, "accounts", acc.Name)
470 err = filepath.WalkDir(srcadir, func(srcapath string, d fs.DirEntry, err error) error {
472 xerrx("walking files in account", err, slog.String("srcpath", srcapath))
478 p := srcapath[len(srcadir)+1:]
479 l := strings.Split(p, string(filepath.Separator))
481 mp := filepath.Join(l[1:]...)
482 if _, ok := seen[mp]; ok {
487 case "index.db", "junkfilter.db", "junkfilter.bloom":
490 ap := filepath.Join("accounts", acc.Name, p)
491 if strings.HasPrefix(p, "msg"+string(filepath.Separator)) {
492 xwarnx("backing up unrecognized file in account message directory (should be moved away)", nil, slog.String("path", ap))
494 xwarnx("backing up unrecognized file in account directory", nil, slog.String("path", ap))
500 xerrx("walking account directory (not backed up properly)", err, slog.String("srcdir", srcadir), slog.Duration("duration", time.Since(tmWalk)))
502 xvlog("walked account directory", slog.Duration("duration", time.Since(tmWalk)))
505 xvlog("account backup finished", slog.String("dir", filepath.Join("accounts", acc.Name)), slog.Duration("duration", time.Since(tmAccount)))
508 // For each configured account, open it, make a copy of the database and
509 // hardlink/copy the messages. We track the accounts we handled, and skip the
510 // account directories when handling "all other files" below.
511 accounts := map[string]struct{}{}
512 for _, accName := range mox.Conf.Accounts() {
513 acc, err := store.OpenAccount(ctl.log, accName)
515 xerrx("opening account for copying (will try to copy as regular files later)", err, slog.String("account", accName))
518 accounts[accName] = struct{}{}
522 // Copy all other files, that aren't part of the known files, databases, queue or accounts.
524 err = filepath.WalkDir(srcDataDir, func(srcpath string, d fs.DirEntry, err error) error {
526 xerrx("walking path", err, slog.String("path", srcpath))
530 if srcpath == srcDataDir {
533 p := srcpath[len(srcDataDir)+1:]
534 if p == "queue" || p == "acme" || p == "tmp" {
537 l := strings.Split(p, string(filepath.Separator))
538 if len(l) >= 2 && l[0] == "accounts" {
540 if _, ok := accounts[name]; ok {
545 // Only files are explicitly backed up.
551 case "dmarcrpt.db", "dmarceval.db", "mtasts.db", "tlsrpt.db", "tlsrptresult.db", "receivedid.key", "ctl":
554 case "lastknownversion": // Optional file, not yet handled.
556 xwarnx("backing up unrecognized file", nil, slog.String("path", p))
562 xerrx("walking other files (not backed up properly)", err, slog.Duration("duration", time.Since(tmWalk)))
564 xvlog("walking other files finished", slog.Duration("duration", time.Since(tmWalk)))
567 xvlog("backup finished", slog.Duration("duration", time.Since(tmStart)))
572 ctl.xwrite("errors were encountered during backup")