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(store.AuthDB, "auth.db")
292 backupDB(dmarcdb.ReportsDB, "dmarcrpt.db")
293 backupDB(dmarcdb.EvalDB, "dmarceval.db")
294 backupDB(mtastsdb.DB, "mtasts.db")
295 backupDB(tlsrptdb.ReportDB, "tlsrpt.db")
296 backupDB(tlsrptdb.ResultDB, "tlsrptresult.db")
297 backupFile("receivedid.key")
299 // Acme directory is optional.
300 srcAcmeDir := filepath.Join(srcDataDir, "acme")
301 if _, err := os.Stat(srcAcmeDir); err == nil {
303 } else if err != nil && !os.IsNotExist(err) {
304 xerrx("copying acme/", err)
307 // Copy the queue database and all message files.
308 backupQueue := func(path string) {
309 tmQueue := time.Now()
311 if err := backupDB(queue.DB, path); err != nil {
312 xerrx("queue not backed up", err, slog.String("path", path), slog.Duration("duration", time.Since(tmQueue)))
316 dstdbpath := filepath.Join(dstDataDir, path)
317 opts := bstore.Options{MustExist: true, RegisterLogger: ctl.log.Logger}
318 db, err := bstore.Open(ctx, dstdbpath, &opts, queue.DBTypes...)
320 xerrx("open copied queue database", err, slog.String("dstpath", dstdbpath), slog.Duration("duration", time.Since(tmQueue)))
327 ctl.log.Check(err, "closing new queue db")
331 // Link/copy known message files. Warn if files are missing or unexpected
332 // (though a message file could have been removed just now due to delivery, or a
333 // new message may have been queued).
335 seen := map[string]struct{}{}
336 var nlinked, ncopied int
337 err = bstore.QueryDB[queue.Msg](ctx, db).ForEach(func(m queue.Msg) error {
338 mp := store.MessagePath(m.ID)
339 seen[mp] = struct{}{}
340 srcpath := filepath.Join(srcDataDir, "queue", mp)
341 dstpath := filepath.Join(dstDataDir, "queue", mp)
342 if linked, err := linkOrCopy(srcpath, dstpath); err != nil {
343 xerrx("linking/copying queue message", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
352 xerrx("processing queue messages (not backed up properly)", err, slog.Duration("duration", time.Since(tmMsgs)))
354 xvlog("queue message files linked/copied",
355 slog.Int("linked", nlinked),
356 slog.Int("copied", ncopied),
357 slog.Duration("duration", time.Since(tmMsgs)))
360 // Read through all files in queue directory and warn about anything we haven't handled yet.
362 srcqdir := filepath.Join(srcDataDir, "queue")
363 err = filepath.WalkDir(srcqdir, func(srcqpath string, d fs.DirEntry, err error) error {
365 xerrx("walking files in queue", err, slog.String("srcpath", srcqpath))
371 p := srcqpath[len(srcqdir)+1:]
372 if _, ok := seen[p]; ok {
378 qp := filepath.Join("queue", p)
379 xwarnx("backing up unrecognized file in queue directory", nil, slog.String("path", qp))
384 xerrx("walking queue directory (not backed up properly)", err, slog.String("dir", "queue"), slog.Duration("duration", time.Since(tmWalk)))
386 xvlog("walked queue directory", slog.Duration("duration", time.Since(tmWalk)))
389 xvlog("queue backed finished", slog.Duration("duration", time.Since(tmQueue)))
391 backupQueue(filepath.FromSlash("queue/index.db"))
393 backupAccount := func(acc *store.Account) {
396 tmAccount := time.Now()
398 // Copy database file.
399 dbpath := filepath.Join("accounts", acc.Name, "index.db")
400 err := backupDB(acc.DB, dbpath)
402 xerrx("copying account database", err, slog.String("path", dbpath), slog.Duration("duration", time.Since(tmAccount)))
405 // todo: should document/check not taking a rlock on account.
407 // Copy junkfilter files, if configured.
408 if jf, _, err := acc.OpenJunkFilter(ctx, ctl.log); err != nil {
409 if !errors.Is(err, store.ErrNoJunkFilter) {
410 xerrx("opening junk filter for account (not backed up)", err)
414 jfpath := filepath.Join("accounts", acc.Name, "junkfilter.db")
416 bloompath := filepath.Join("accounts", acc.Name, "junkfilter.bloom")
417 backupFile(bloompath)
420 ctl.log.Check(err, "closing junkfilter")
423 dstdbpath := filepath.Join(dstDataDir, dbpath)
424 opts := bstore.Options{MustExist: true, RegisterLogger: ctl.log.Logger}
425 db, err := bstore.Open(ctx, dstdbpath, &opts, store.DBTypes...)
427 xerrx("open copied account database", err, slog.String("dstpath", dstdbpath), slog.Duration("duration", time.Since(tmAccount)))
434 ctl.log.Check(err, "close account database")
438 // Link/copy known message files. Warn if files are missing or unexpected (though a
439 // message file could have been added just now due to delivery, or a message have
442 seen := map[string]struct{}{}
443 var nlinked, ncopied int
444 err = bstore.QueryDB[store.Message](ctx, db).FilterEqual("Expunged", false).ForEach(func(m store.Message) error {
445 mp := store.MessagePath(m.ID)
446 seen[mp] = struct{}{}
447 amp := filepath.Join("accounts", acc.Name, "msg", mp)
448 srcpath := filepath.Join(srcDataDir, amp)
449 dstpath := filepath.Join(dstDataDir, amp)
450 if linked, err := linkOrCopy(srcpath, dstpath); err != nil {
451 xerrx("linking/copying account message", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
460 xerrx("processing account messages (not backed up properly)", err, slog.Duration("duration", time.Since(tmMsgs)))
462 xvlog("account message files linked/copied",
463 slog.Int("linked", nlinked),
464 slog.Int("copied", ncopied),
465 slog.Duration("duration", time.Since(tmMsgs)))
468 // Read through all files in account directory and warn about anything we haven't handled yet.
470 srcadir := filepath.Join(srcDataDir, "accounts", acc.Name)
471 err = filepath.WalkDir(srcadir, func(srcapath string, d fs.DirEntry, err error) error {
473 xerrx("walking files in account", err, slog.String("srcpath", srcapath))
479 p := srcapath[len(srcadir)+1:]
480 l := strings.Split(p, string(filepath.Separator))
482 mp := filepath.Join(l[1:]...)
483 if _, ok := seen[mp]; ok {
488 case "index.db", "junkfilter.db", "junkfilter.bloom":
491 ap := filepath.Join("accounts", acc.Name, p)
492 if strings.HasPrefix(p, "msg"+string(filepath.Separator)) {
493 xwarnx("backing up unrecognized file in account message directory (should be moved away)", nil, slog.String("path", ap))
495 xwarnx("backing up unrecognized file in account directory", nil, slog.String("path", ap))
501 xerrx("walking account directory (not backed up properly)", err, slog.String("srcdir", srcadir), slog.Duration("duration", time.Since(tmWalk)))
503 xvlog("walked account directory", slog.Duration("duration", time.Since(tmWalk)))
506 xvlog("account backup finished", slog.String("dir", filepath.Join("accounts", acc.Name)), slog.Duration("duration", time.Since(tmAccount)))
509 // For each configured account, open it, make a copy of the database and
510 // hardlink/copy the messages. We track the accounts we handled, and skip the
511 // account directories when handling "all other files" below.
512 accounts := map[string]struct{}{}
513 for _, accName := range mox.Conf.Accounts() {
514 acc, err := store.OpenAccount(ctl.log, accName)
516 xerrx("opening account for copying (will try to copy as regular files later)", err, slog.String("account", accName))
519 accounts[accName] = struct{}{}
523 // Copy all other files, that aren't part of the known files, databases, queue or accounts.
525 err = filepath.WalkDir(srcDataDir, func(srcpath string, d fs.DirEntry, err error) error {
527 xerrx("walking path", err, slog.String("path", srcpath))
531 if srcpath == srcDataDir {
534 p := srcpath[len(srcDataDir)+1:]
535 if p == "queue" || p == "acme" || p == "tmp" {
538 l := strings.Split(p, string(filepath.Separator))
539 if len(l) >= 2 && l[0] == "accounts" {
541 if _, ok := accounts[name]; ok {
546 // Only files are explicitly backed up.
552 case "auth.db", "dmarcrpt.db", "dmarceval.db", "mtasts.db", "tlsrpt.db", "tlsrptresult.db", "receivedid.key", "ctl":
555 case "lastknownversion": // Optional file, not yet handled.
557 xwarnx("backing up unrecognized file", nil, slog.String("path", p))
563 xerrx("walking other files (not backed up properly)", err, slog.Duration("duration", time.Since(tmWalk)))
565 xvlog("walking other files finished", slog.Duration("duration", time.Since(tmWalk)))
568 xvlog("backup finished", slog.Duration("duration", time.Since(tmStart)))
573 ctl.xwrite("errors were encountered during backup")