e2e: full backup from the panel, restore into an empty directory

After the rate-limit checks the run takes a full backup through the
panel, reads the archive through while the container can still say what
went wrong, then removes data/, certs/, docker-compose.yml and .env,
unpacks the archive into the project directory, puts the private key
back (the panel's archive comes without it and without the queue, both
asserted), and starts the stand again.

What has to hold: manifest.json is consumed by the first start, the
administrator signs in, the domain shows the same DKIM value, and a
message sent with the application's old password reaches the sink and
verifies against the record published before the backup.

The port-25 scenarios then run on the restored installation.

make e2e on the test server: ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-11 19:55:19 +03:00
co-authored by Claude Opus 5.5
parent 8c9a71e6a0
commit b55b33ae92
3 changed files with 217 additions and 1 deletions
+19
View File
@@ -19,6 +19,20 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
- DMARC reports are kept 500 **for each domain** (and 90 days), not 500 for - DMARC reports are kept 500 **for each domain** (and 90 days), not 500 for
the whole server - see Security. the whole server - see Security.
### Fixed
- **Full backup from the panel works.** Since the Postfix queue moved under
`/data` in 1.3.0 the button answered with a 200 and ten bytes: the archive
includes the queue, Postfix keeps the queue readable by its own user alone,
and the panel is not that user ("open /data/postfix/queue/active:
permission denied" in the log). `selfpost-backup` run with `docker exec`
was not affected - it runs as root. The panel's archive now leaves the queue
out, and likewise a file in `certs/` it may not open (usually the private
key, named in the log); the page, Help and the guide say so, and the guide
says to put the key back before starting a restored instance. Everything is
read through before the download starts, so a failure is a message on the
page and not a file cut short.
### Security ### Security
Review of the DMARC report path (Fable, 2026-10-11), which started to run for Review of the DMARC report path (Fable, 2026-10-11), which started to run for
@@ -49,6 +63,11 @@ the first time in 2.0.0. Nothing Critical or High.
report are accepted and dropped; mail for an inbound domain reaches its report are accepted and dropped; mail for an inbound domain reaches its
upstream and other recipients are refused. Until now the suite only read upstream and other recipients are refused. Until now the suite only read
back the generated configuration of these features. back the generated configuration of these features.
- e2e: full backup and restore. The run takes a backup from the panel, throws
the installation away, unpacks the archive into the empty project directory
and starts it: the administrator signs in, the domain has the same DKIM key,
and mail sent by the application verifies against the record published
before the backup.
## [2.0.0] - 2026-10-11 ## [2.0.0] - 2026-10-11
+5 -1
View File
@@ -275,7 +275,11 @@ func TestE2E(t *testing.T) {
testLevel1RateLimit(t, sc) testLevel1RateLimit(t, sc)
}) })
// From here on the stand accepts mail on port 25. // From here on the installation is no longer the one the run started: it
// is restored from its own backup, then switched to accept mail on port 25.
run("full_backup_restore", func(t *testing.T) {
testFullBackupRestore(t, sc)
})
run("enable_port25_features", func(t *testing.T) { run("enable_port25_features", func(t *testing.T) {
testEnablePort25Features(t, sc) testEnablePort25Features(t, sc)
}) })
+193
View File
@@ -0,0 +1,193 @@
package e2e
import (
"archive/tar"
"bytes"
"compress/gzip"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// testFullBackupRestore takes a full backup from the panel, throws the
// installation away and brings it back the way the guide tells an operator to
// (§ Full backup and restore): unpack the archive into an empty project
// directory, put the private key back, start the same image. What has to come back is what an operator
// cannot recreate - the administrator, the domain with its DKIM key, the
// application with its password - and the proof is the same one the first
// send gave: a message signed with the restored key verifies against the
// record that was published before the backup.
func testFullBackupRestore(t *testing.T, sc *scenario) {
resp, archive, err := sc.panel.postForm("/server/backup", url.Values{})
if err != nil {
t.Fatalf("full backup: %v", err)
}
if ct := resp.Header.Get("Content-Type"); resp.StatusCode != 200 || ct != "application/gzip" {
t.Fatalf("full backup: status %d, Content-Type %q, want an application/gzip download:\n%.600s", resp.StatusCode, ct, archive)
}
// A download that broke off half way is still a 200: the panel streams the
// archive and can only log what went wrong. Read it through once while the
// container that wrote it can still say why.
if _, err := untar(t.TempDir(), []byte(archive)); err != nil {
t.Fatalf("the backup the panel sent (%d bytes) is not a complete archive: %v\n%s", len(archive), err, logTail(h.logs("selfpost"), "backup"))
}
// The installation goes: containers down, then everything the archive is
// supposed to bring back. The fake zone and the sink's directory are the
// outside world and stay.
// The private key is the operator's to keep: the panel may not read it (it
// is the host user's, mode 0600, here as on a real server) and its archive
// comes without it.
keyPath := filepath.Join(h.stageDir, "certs", "privkey.pem")
key, err := os.ReadFile(keyPath)
if err != nil {
t.Fatalf("read the stand's private key: %v", err)
}
h.reclaimData()
h.down()
for _, name := range []string{"data", "certs", "docker-compose.yml", ".env"} {
if err := removeAllBestEffort(filepath.Join(h.stageDir, name)); err != nil {
t.Fatalf("remove %s before restore: %v", name, err)
}
}
names, err := untar(h.stageDir, []byte(archive))
if err != nil {
t.Fatalf("unpack the backup into the empty project directory: %v", err)
}
for _, want := range []string{"docker-compose.yml", ".env", "data/manifest.json", "data/selfpost.db", "certs/fullchain.pem"} {
if !names[want] {
t.Fatalf("the backup has no %s (it has %d entries)", want, len(names))
}
}
// The panel's archive is the one without the Postfix queue (guide § Taking
// a full backup): the restored instance has to build an empty one.
for name := range names {
if name == "data/postfix/queue" || strings.HasPrefix(name, "data/postfix/queue/") {
t.Fatalf("the panel's backup holds %s", name)
}
}
if !names["certs/privkey.pem"] {
if err := os.WriteFile(keyPath, key, 0o600); err != nil {
t.Fatalf("put the private key back: %v", err)
}
}
if err := h.up(); err != nil {
t.Fatalf("start the restored installation: %v", err)
}
if err := waitForPanelReady(); err != nil {
t.Fatalf("the restored installation did not come up: %v\n%s", err, h.logs("selfpost"))
}
if err := waitForSMTPSReady(60 * time.Second); err != nil {
t.Fatal(err)
}
// The manifest guards the first start only and is removed by it.
if err := waitFor("manifest.json to be removed by the first start", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
out, _ := h.execIn("selfpost", "sh", "-c", "test -e /data/manifest.json && echo present || echo gone")
return strings.TrimSpace(out) == "gone", fmt.Errorf("manifest.json is %s", strings.TrimSpace(out))
}); err != nil {
t.Fatal(err)
}
p, err := newPanelClient()
if err != nil {
t.Fatal(err)
}
if err := p.login(adminUser, adminPassword); err != nil {
t.Fatalf("the administrator cannot sign in after the restore: %v", err)
}
sc.panel = p
_, value, err := p.dkimRecord(sc.domainID)
if err != nil {
t.Fatalf("the domain is not there after the restore: %v", err)
}
if value != sc.dkimValue {
t.Fatalf("the domain came back with another DKIM key:\n before %s\n after %s", sc.dkimValue, value)
}
token := uniqueToken("restored")
res := attemptSend(sendAttempt{
authLogin: sc.appLogin, authPassword: sc.appPassword,
from: "alerts@" + senderDomain, to: recipient,
subject: "e2e " + token, body: "sent by the restored installation",
})
if !res.ok() {
t.Fatalf("the application cannot send after the restore: %v", res.firstErr())
}
raw, err := findSinkMessage(h.stageDir, token, 30*time.Second)
if err != nil {
t.Fatalf("the restored installation did not deliver: %v", err)
}
if err := verifyDKIM(raw, senderDomain); err != nil {
t.Fatalf("mail signed after the restore does not verify against the record published before it: %v", err)
}
}
// untar unpacks a .tar.gz into dir and returns the names it held. Only
// directories and regular files are expected in a backup; anything else, or a
// name that leaves dir, is an error rather than something to skip quietly.
func untar(dir string, archive []byte) (map[string]bool, error) {
zr, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
return nil, err
}
names := map[string]bool{}
tr := tar.NewReader(zr)
for {
hdr, err := tr.Next()
if err == io.EOF {
return names, nil
}
if err != nil {
return nil, err
}
name := filepath.ToSlash(filepath.Clean(hdr.Name))
if name == ".." || strings.HasPrefix(name, "../") || filepath.IsAbs(hdr.Name) {
return nil, fmt.Errorf("entry %q leaves the directory", hdr.Name)
}
target := filepath.Join(dir, filepath.FromSlash(name))
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return nil, err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return nil, err
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode().Perm())
if err != nil {
return nil, err
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
return nil, err
}
if err := f.Close(); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("entry %q has type %q, neither a directory nor a regular file", hdr.Name, hdr.Typeflag)
}
names[name] = true
}
}
// logTail returns the lines of log that contain filter.
func logTail(log, filter string) string {
var b strings.Builder
for _, line := range strings.Split(log, "\n") {
if strings.Contains(line, filter) {
b.WriteString(line + "\n")
}
}
return b.String()
}