346 lines
11 KiB
Go
346 lines
11 KiB
Go
// Package backup implements whole-server backup/restore for server_data — the SQLite
|
|
// DB, the encrypted mailstore blobs, and the master key that unlocks them (see
|
|
// internal/mailstore for why losing that key makes every already-stored message
|
|
// permanently unrecoverable, even for admins). A backup is a tar.gz of the directory,
|
|
// optionally sealed under an admin-supplied passphrase (scrypt-derived key,
|
|
// AES-256-GCM) so the resulting file is safe to store somewhere the operator doesn't
|
|
// otherwise fully trust.
|
|
package backup
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"golang.org/x/crypto/scrypt"
|
|
)
|
|
|
|
// magicPlain/magicEncrypted are the first 8 bytes of every archive this package
|
|
// writes, letting Restore tell the two forms apart (and reject anything else) without
|
|
// guessing from gzip's own magic bytes, which an encrypted blob could in principle
|
|
// collide with.
|
|
var (
|
|
magicPlain = [8]byte{'M', 'T', 'A', 'B', 'K', 'U', 'P', '0'}
|
|
magicEncrypted = [8]byte{'M', 'T', 'A', 'B', 'K', 'U', 'P', '1'}
|
|
)
|
|
|
|
const (
|
|
scryptN = 1 << 15 // 32768 — interactive-login-strength cost, fine for an admin-initiated one-off operation
|
|
scryptR = 8
|
|
scryptP = 1
|
|
scryptKeyLen = 32 // AES-256
|
|
saltSize = 16
|
|
)
|
|
|
|
// WriteServer tars+gzips every file under dataDir into w. If passphrase is non-empty,
|
|
// the archive is additionally sealed with a fresh scrypt-derived key — this buffers the
|
|
// full compressed archive (in a temp file, not memory) before sealing, since AES-GCM
|
|
// has no streaming mode in the standard library; the plain (no-passphrase) path streams
|
|
// directly with no buffering.
|
|
//
|
|
// ponytail: buffers the whole archive on the encrypted path. Fine for a mail server's
|
|
// server_data/ at any size this project is likely to see; move to a chunked/streaming
|
|
// AEAD construction if a deployment's mailstore grows large enough for that to matter.
|
|
func WriteServer(w io.Writer, dataDir, passphrase string) error {
|
|
return WriteServerWithOverride(w, dataDir, "", "", passphrase)
|
|
}
|
|
|
|
// WriteServerExcluding behaves like WriteServer, but skips the directory at
|
|
// dataDir/excludeRelDir (a "/"-separated path relative to dataDir) entirely. Used by
|
|
// scheduled backups, whose destination directory lives inside dataDir itself (the
|
|
// default server_data/backups layout) — without this, each new backup would bundle up
|
|
// every prior backup already sitting in that directory, ballooning in size forever.
|
|
func WriteServerExcluding(w io.Writer, dataDir, excludeRelDir, passphrase string) error {
|
|
return writeServer(w, dataDir, "", "", excludeRelDir, passphrase)
|
|
}
|
|
|
|
// DBExecer is the minimal interface WriteServerConsistent needs to snapshot a live
|
|
// SQLite database via "VACUUM INTO" — satisfied directly by *sql.DB, and by
|
|
// mailgoserver/internal/db.DB, which embeds one.
|
|
type DBExecer interface {
|
|
Exec(query string, args ...any) (sql.Result, error)
|
|
}
|
|
|
|
// WriteServerConsistent snapshots the database reachable through execer via SQLite's
|
|
// "VACUUM INTO" (a consistent point-in-time copy, safe even while the live server
|
|
// keeps writing to dbPath — a raw filesystem copy of a SQLite file caught mid-write
|
|
// could otherwise capture a torn, inconsistent state) and substitutes that snapshot for
|
|
// the live database file when archiving dataDir. Falls back to archiving dataDir
|
|
// exactly as WriteServer would (no substitution) if dbPath isn't actually located
|
|
// under dataDir (a non-default DATABASE_URL) — a known, accepted limitation, see this
|
|
// package's doc comment.
|
|
func WriteServerConsistent(execer DBExecer, w io.Writer, dataDir, dbPath, passphrase string) error {
|
|
return WriteServerConsistentExcluding(execer, w, dataDir, dbPath, "", passphrase)
|
|
}
|
|
|
|
// WriteServerConsistentExcluding behaves like WriteServerConsistent, but additionally
|
|
// skips the directory at dataDir/excludeRelDir — see WriteServerExcluding.
|
|
func WriteServerConsistentExcluding(execer DBExecer, w io.Writer, dataDir, dbPath, excludeRelDir, passphrase string) error {
|
|
snapshot, err := os.CreateTemp("", "mtabackup-db-*.db")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
snapshotPath := snapshot.Name()
|
|
snapshot.Close()
|
|
os.Remove(snapshotPath) // VACUUM INTO refuses to write to a file that already exists
|
|
defer os.Remove(snapshotPath)
|
|
|
|
if _, err := execer.Exec(`VACUUM INTO ?`, snapshotPath); err != nil {
|
|
return fmt.Errorf("snapshot database: %w", err)
|
|
}
|
|
|
|
dbRel, err := filepath.Rel(dataDir, dbPath)
|
|
if err != nil || strings.HasPrefix(dbRel, "..") {
|
|
return writeServer(w, dataDir, "", "", excludeRelDir, passphrase)
|
|
}
|
|
return writeServer(w, dataDir, filepath.ToSlash(dbRel), snapshotPath, excludeRelDir, passphrase)
|
|
}
|
|
|
|
// WriteServerWithOverride behaves like WriteServer, but the file at dataDir/relPath (a
|
|
// "/"-separated path relative to dataDir) has its content read from overridePath
|
|
// instead of its own on-disk location — used to substitute a point-in-time-consistent
|
|
// database snapshot (produced via SQLite's "VACUUM INTO") for the live database file,
|
|
// which could otherwise be caught mid-write, without needing to stage a full copy of
|
|
// dataDir just to swap out one file. Pass relPath == "" to disable the substitution
|
|
// (equivalent to WriteServer).
|
|
func WriteServerWithOverride(w io.Writer, dataDir, relPath, overridePath, passphrase string) error {
|
|
return writeServer(w, dataDir, relPath, overridePath, "", passphrase)
|
|
}
|
|
|
|
func writeServer(w io.Writer, dataDir, relPath, overridePath, excludeRelDir, passphrase string) error {
|
|
if passphrase == "" {
|
|
if _, err := w.Write(magicPlain[:]); err != nil {
|
|
return err
|
|
}
|
|
return tarGzDir(w, dataDir, relPath, overridePath, excludeRelDir)
|
|
}
|
|
|
|
tmp, err := os.CreateTemp("", "mtabackup-*.tar.gz")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
defer tmp.Close()
|
|
|
|
if err := tarGzDir(tmp, dataDir, relPath, overridePath, excludeRelDir); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
|
return err
|
|
}
|
|
plaintext, err := io.ReadAll(tmp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
salt := make([]byte, saltSize)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return err
|
|
}
|
|
key, err := scrypt.Key([]byte(passphrase), salt, scryptN, scryptR, scryptP, scryptKeyLen)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return err
|
|
}
|
|
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
|
|
|
|
if _, err := w.Write(magicEncrypted[:]); err != nil {
|
|
return err
|
|
}
|
|
if _, err := w.Write(salt); err != nil {
|
|
return err
|
|
}
|
|
if _, err := w.Write(nonce); err != nil {
|
|
return err
|
|
}
|
|
_, err = w.Write(ciphertext)
|
|
return err
|
|
}
|
|
|
|
func tarGzDir(w io.Writer, dataDir, overrideRelPath, overridePath, excludeRelDir string) error {
|
|
gzw := gzip.NewWriter(w)
|
|
tw := tar.NewWriter(gzw)
|
|
err := filepath.Walk(dataDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(dataDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
relSlash := filepath.ToSlash(rel)
|
|
if excludeRelDir != "" && (relSlash == excludeRelDir || strings.HasPrefix(relSlash, excludeRelDir+"/")) {
|
|
if info.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
readPath := path
|
|
if overrideRelPath != "" && relSlash == overrideRelPath {
|
|
path = overridePath
|
|
readPath = overridePath
|
|
if info, err = os.Stat(overridePath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
hdr, err := tar.FileInfoHeader(info, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hdr.Name = relSlash
|
|
if info.IsDir() {
|
|
hdr.Name += "/"
|
|
}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
return err
|
|
}
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
f, err := os.Open(readPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
_, err = io.Copy(tw, f)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
return err
|
|
}
|
|
return gzw.Close()
|
|
}
|
|
|
|
// RestoreServer extracts an archive written by WriteServer into dataDir, refusing to
|
|
// overwrite a non-empty dataDir unless force is true. passphrase is required (and
|
|
// must match) for an archive that was sealed with one; ignored for a plain archive.
|
|
func RestoreServer(r io.Reader, dataDir, passphrase string, force bool) error {
|
|
entries, err := os.ReadDir(dataDir)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
if len(entries) > 0 && !force {
|
|
return fmt.Errorf("restore target %s is not empty (pass force to overwrite)", dataDir)
|
|
}
|
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
var magic [8]byte
|
|
if _, err := io.ReadFull(r, magic[:]); err != nil {
|
|
return fmt.Errorf("reading archive header: %w", err)
|
|
}
|
|
|
|
var tarGzReader io.Reader
|
|
switch magic {
|
|
case magicPlain:
|
|
tarGzReader = r
|
|
case magicEncrypted:
|
|
if passphrase == "" {
|
|
return errors.New("this backup is passphrase-protected; a passphrase is required to restore it")
|
|
}
|
|
salt := make([]byte, saltSize)
|
|
if _, err := io.ReadFull(r, salt); err != nil {
|
|
return fmt.Errorf("reading salt: %w", err)
|
|
}
|
|
key, err := scrypt.Key([]byte(passphrase), salt, scryptN, scryptR, scryptP, scryptKeyLen)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := io.ReadFull(r, nonce); err != nil {
|
|
return fmt.Errorf("reading nonce: %w", err)
|
|
}
|
|
ciphertext, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
return errors.New("wrong passphrase or corrupted backup")
|
|
}
|
|
tarGzReader = strings.NewReader(string(plaintext))
|
|
default:
|
|
return errors.New("not a recognized backup file")
|
|
}
|
|
|
|
gzr, err := gzip.NewReader(tarGzReader)
|
|
if err != nil {
|
|
return fmt.Errorf("opening archive: %w", err)
|
|
}
|
|
defer gzr.Close()
|
|
tr := tar.NewReader(gzr)
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if err == io.EOF {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// filepath.Join already collapses ".." segments against dataDir, but a
|
|
// crafted "../../etc/passwd" entry could still walk outside it — reject
|
|
// anything that doesn't stay under dataDir rather than trust archive
|
|
// contents, since this may be restoring a file an admin downloaded/moved.
|
|
target := filepath.Join(dataDir, filepath.FromSlash(hdr.Name))
|
|
if !strings.HasPrefix(target, filepath.Clean(dataDir)+string(os.PathSeparator)) && target != filepath.Clean(dataDir) {
|
|
return fmt.Errorf("archive entry %q escapes the restore target", hdr.Name)
|
|
}
|
|
switch hdr.Typeflag {
|
|
case tar.TypeDir:
|
|
if err := os.MkdirAll(target, os.FileMode(hdr.Mode)); err != nil {
|
|
return err
|
|
}
|
|
case tar.TypeReg:
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
return err
|
|
}
|
|
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(f, tr); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|