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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user