Files
mailgoserver/internal/backup/server.go
T

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
}
}
}
}