backup: the panel's full backup works, without the queue

Found by the e2e restore scenario of the next commit: the Full backup
button answered 200 with ten bytes. The archive includes the Postfix
queue, Postfix keeps every queue file and directory readable by its own
user alone (not configurable), and the panel runs as its own user - the
walk stopped at /data/postfix/queue/active with "permission denied"
after the headers were out. So since the queue moved under /data in
1.3.0. Confirmed on the test server: selfpost-backup as root makes the
whole archive, the same command as user panel fails the same way.

Owner, 2026-10-11: the panel's archive comes without the queue.

- backup.Params.Unprivileged: the queue is left out, and so is a file
  under certs/ that cannot be opened (the private key on the test
  server is root and Postfix's group only), named through OnWarn. The
  selfpost-backup command leaves it unset and takes both, as before.
- backup.Check reads through everything Create would archive, writing
  nothing. The panel runs it before the first byte: a failure is a
  message on the page, not a download cut short.
- The box, Help (design commit before this one) and the guide say what
  the panel's archive leaves out; the guide's comparison table has rows
  for the queue and the private key, and tells to put the key back
  before starting a restored instance.

  go vet ./internal/backup/ ./internal/web/handlers/: ok
  go test ./internal/backup/ ./cmd/... ./internal/web/...: ok
  make e2e on the test server, with the restore scenario: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-11 19:55:18 +03:00
co-authored by Claude Opus 5.5
parent 646ec1e149
commit 8c9a71e6a0
6 changed files with 184 additions and 20 deletions
+98 -6
View File
@@ -28,6 +28,7 @@ import (
"compress/gzip"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
@@ -79,8 +80,25 @@ type Params struct {
Version string
DeployRoot string
OnWarn func(string)
// Unprivileged says the archive is made by the panel, which runs as its
// own user and not as root. Two things under the roots are then not its to
// read, and are left out instead of failing the whole archive:
//
// - the Postfix queue (QueueDir). Postfix keeps every queue file and
// directory readable by its own user alone, and that cannot be
// configured. What is lost is mail not yet delivered at that moment;
// a start without a queue directory builds an empty one.
// - a file under certs/ the panel may not open, which is usually the
// private key (root and Postfix's group only). OnWarn names it.
//
// The selfpost-backup command, run as root, leaves this unset and takes
// both.
Unprivileged bool
}
// QueueDir is the Postfix queue, relative to the data directory.
const QueueDir = "postfix/queue"
// excludedFromArchive lists the data-directory entries a backup never carries.
// The live database files are replaced by a consistent VACUUM INTO snapshot
// written under the canonical name; the setup token is transient bootstrap
@@ -145,11 +163,11 @@ func Create(w io.Writer, p Params) error {
return err
}
if err := addTree(tw, p.DataDir, DataArchivePrefix, excludedFromArchive); err != nil {
if err := addTree(tw, p.DataDir, DataArchivePrefix, excludedFromArchive, p.skipData()); err != nil {
return err
}
if err := addDeployFiles(tw, p.DeployRoot, p.OnWarn); err != nil {
if err := addDeployFiles(tw, p.DeployRoot, p.OnWarn, p.skipCert()); err != nil {
return err
}
@@ -181,7 +199,66 @@ func validateDeployRoot(deployRoot string) error {
return nil
}
func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string)) error {
// skipFunc says whether the entry at path (rel to the root being walked) is
// left out of the archive. A directory left out is not descended into.
type skipFunc func(path, rel string, d fs.DirEntry) bool
// skipData leaves the Postfix queue out of an unprivileged archive.
func (p Params) skipData() skipFunc {
if !p.Unprivileged {
return nil
}
return func(_, rel string, _ fs.DirEntry) bool {
return filepath.ToSlash(rel) == QueueDir
}
}
// skipCert leaves a file under certs/ that cannot be opened out of an
// unprivileged archive, and says so through OnWarn.
func (p Params) skipCert() skipFunc {
if !p.Unprivileged {
return nil
}
return func(path, rel string, d fs.DirEntry) bool {
if d.IsDir() {
return false
}
f, err := os.Open(path)
if err == nil {
f.Close()
return false
}
if !errors.Is(err, fs.ErrPermission) {
return false
}
if p.OnWarn != nil {
p.OnWarn(CertsDirName + "/" + filepath.ToSlash(rel) + " is not readable by the panel and is left out of the backup")
}
return true
}
}
// Check reads through everything Create would put into the archive, without
// writing one, and returns the first thing that cannot be read. The panel
// streams its archive to the browser, so once Create has begun a failure can
// only cut the download short; this is run first, while a refusal can still be
// shown as a page.
func Check(p Params) error {
if p.DataDir == "" || p.DBPath == "" {
return fmt.Errorf("backup: DataDir and DBPath are required")
}
if err := ValidateDeployRoot(p.DeployRoot); err != nil {
return err
}
quiet := p
quiet.OnWarn = nil
if err := addTree(nil, p.DataDir, DataArchivePrefix, excludedFromArchive, quiet.skipData()); err != nil {
return err
}
return addDeployFiles(nil, p.DeployRoot, nil, quiet.skipCert())
}
func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string), skip skipFunc) error {
for _, name := range []string{ComposeFileName, EnvFileName} {
src := filepath.Join(deployRoot, name)
info, err := os.Stat(src)
@@ -206,14 +283,16 @@ func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string)) erro
}
return fmt.Errorf("backup: stat %s: %w", CertsDirName, err)
}
return addTree(tw, certsDir, CertsDirName+"/", nil)
return addTree(tw, certsDir, CertsDirName+"/", nil, skip)
}
// addTree walks root and adds every regular file (and directory, to preserve
// empty ones and modes) to tw under archivePrefix + path relative to root.
// When exclude is non-nil, top-level names relative to root are skipped.
// When exclude is non-nil, top-level names relative to root are skipped; skip,
// when non-nil, leaves out whatever it says. With a nil tw nothing is written:
// every file is opened and closed, which is how Check finds what cannot be read.
// Non-regular, non-directory entries (symlinks, sockets) are skipped.
func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool) error {
func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool, skip skipFunc) error {
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
@@ -236,12 +315,22 @@ func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool
}
}
if skip != nil && skip(path, rel, d) {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
info, err := d.Info()
if err != nil {
return err
}
switch {
case d.IsDir():
if tw == nil {
return nil
}
hdr := &tar.Header{
Typeflag: tar.TypeDir,
Name: name + "/",
@@ -282,6 +371,9 @@ func writeTarFile(tw *tar.Writer, name string, mode fs.FileMode, srcPath string)
return fmt.Errorf("backup: open %s: %w", srcPath, err)
}
defer f.Close()
if tw == nil {
return nil
}
info, err := f.Stat()
if err != nil {
+44
View File
@@ -235,3 +235,47 @@ func TestCheckRestoreWrongFormatRejected(t *testing.T) {
t.Error("CheckRestore wrong format reported a restore")
}
}
// The panel's archive: no Postfix queue, everything else as before, and Check
// agrees that it can be made.
func TestCreateUnprivilegedLeavesTheQueueOut(t *testing.T) {
dataDir, dbPath, deployRoot := seedDataDir(t)
p := Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot, Unprivileged: true}
if err := Check(p); err != nil {
t.Fatalf("Check: %v", err)
}
var buf bytes.Buffer
if err := Create(&buf, p); err != nil {
t.Fatalf("Create: %v", err)
}
files := readArchive(t, buf.Bytes())
for name := range files {
if strings.HasPrefix(name, DataArchivePrefix+QueueDir) {
t.Errorf("the panel's archive holds %s", name)
}
}
for _, name := range []string{
DataArchivePrefix + "selfpost.db",
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
DataArchivePrefix + "sasl/sasldb2",
DataArchivePrefix + "postfix/sender_login_maps",
ComposeFileName, EnvFileName,
CertsDirName + "/fullchain.pem",
} {
if _, ok := files[name]; !ok {
t.Errorf("archive missing %s", name)
}
}
}
// Check finds what Create would stumble over, before anything is written.
func TestCheckReportsWhatCannotBeRead(t *testing.T) {
dataDir, dbPath, deployRoot := seedDataDir(t)
if err := os.Remove(filepath.Join(deployRoot, EnvFileName)); err != nil {
t.Fatal(err)
}
if err := Check(Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot}); err == nil {
t.Fatal("Check passed with .env missing from the deploy root")
}
}