Files
mix e70ba9046e fix(panel): resync mail-path maps once after restore
When CheckRestore accepts a backup manifest, the panel re-derives OpenDKIM tables and the Postfix sender map from SQLite on that first boot and reloads both daemons, so archive/database drift is healed before mail flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 14:58:08 +03:00

457 lines
16 KiB
Go

package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/store"
"golang.org/x/crypto/bcrypt"
)
// Restoring a SelfPost backup is not a code path in the panel: the operator
// extracts the archive into the /data bind mount and starts the image, and the
// panel is expected to come up on it (architecture.md § Persistence). Nothing
// below stubs that story out — the archive is downloaded from a running panel
// through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel
// is started on the result through the same startup sequence run() uses:
// CheckRestore, store.Open, newPanel, Start.
const (
restorePassword = "correct-horse-battery"
restoreDomain = "bs.example.ru"
restoreSubject = "Order confirmation"
)
// restored is the outcome of a full backup-and-restore round trip.
type restored struct {
panel http.Handler // panel booted on the restored data directory
dataDir string // the restored /data
session *http.Cookie // a session opened before the backup was taken
}
// restoreFromOwnBackup runs the operator's path end to end: seed a panel that
// has been in use, sign in, download a backup from it, extract that archive
// into an empty directory and boot a second panel there. A non-empty password
// takes the encrypted download and decrypts it on the way in, which is what an
// operator does with a .spbk file.
func restoreFromOwnBackup(t *testing.T, password string) restored {
t.Helper()
live := seedPanelData(t)
panel := bootPanel(t, live)
session := signIn(t, panel)
archive := downloadBackup(t, panel, session, password)
target := t.TempDir()
extract(t, archive, target)
return restored{panel: bootPanel(t, target), dataDir: target, session: session}
}
// The panel has to come up on the restored directory and show the state that
// was in the archive, without the operator touching anything else: the domain
// and its journal are in the database the archive carried, and the credentials
// that worked before the restore still work after it.
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
r := restoreFromOwnBackup(t, "")
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
for _, want := range []string{restoreDomain, restoreSubject} {
if !strings.Contains(body, want) {
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
}
}
// The archive carries the daemon files, and the first boot after restore
// re-derives the maps from SQLite so they stay aligned with the database.
for path, want := range map[string]string{
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
} {
got, err := os.ReadFile(filepath.Join(r.dataDir, path))
if err != nil {
t.Errorf("the restored data directory has no %s: %v", path, err)
continue
}
if string(got) != want {
t.Errorf("%s = %q, want %q", path, got, want)
}
}
}
// The one-time setup link is closed by the presence of a panel user, and the
// restored database has one. A restore that reopened it would publish a link
// that creates a second global administrator on a server holding live mail
// credentials (security.md).
func TestARestoreDoesNotReopenTheSetupLink(t *testing.T) {
r := restoreFromOwnBackup(t, "")
if _, err := os.Stat(filepath.Join(r.dataDir, "setup-token")); !os.IsNotExist(err) {
t.Errorf("the restored panel wrote a setup token (stat err = %v)", err)
}
body := getPage(t, r.panel, "/login", nil)
if strings.Contains(body, "No administrator has been created yet") {
t.Errorf("the restored panel offers first-run setup:\n%s", body)
}
}
// Sessions live in the database, so they travel in the archive: a cookie that
// was valid when the backup was taken is valid again on the restored panel.
// That is the documented consequence of restoring an older backup (guide §
// Backup and restore) — stated here so it cannot change by accident.
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
r := restoreFromOwnBackup(t, "")
rec := request(t, r.panel, http.MethodGet, "/deliveries", nil, r.session)
if rec.Code != http.StatusOK {
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
}
}
// An encrypted download is the same archive inside an envelope, so it restores
// the same way once the password is supplied. The archive is never written to
// disk in the clear by the panel, so this is the only place the two paths can
// be shown to agree.
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
r := restoreFromOwnBackup(t, "a-long-enough-password")
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
if !strings.Contains(body, restoreSubject) {
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
}
}
// A restore boot runs one Resync from SQLite. If the archive's Postfix map
// drifted from the database, that step puts it back before mail flows.
func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
dataDir := seedPanelData(t)
cfg := panelConfig(t, dataDir)
mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps")
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
t.Fatalf("write stale map: %v", err)
}
manifest, err := json.Marshal(backup.Manifest{
Format: backup.FormatFull,
Version: buildinfo.Version,
CreatedAt: "2026-08-14T00:00:00Z",
})
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}
if err := os.WriteFile(cfg.manifestPath, manifest, 0o644); err != nil {
t.Fatalf("write manifest: %v", err)
}
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil {
t.Fatalf("CheckRestore: %v", err)
}
if !restored {
t.Fatal("CheckRestore did not report a restore")
}
st, err := store.Open(cfg.dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
if err := resyncAfterRestore(cfg, st, true); err != nil {
t.Fatalf("resync after restore: %v", err)
}
got, err := os.ReadFile(mapPath)
if err != nil {
t.Fatalf("read sender map: %v", err)
}
want := "@" + restoreDomain + " shop\n"
if string(got) != want {
t.Errorf("sender map = %q, want %q", got, want)
}
}
// The version guard is what stops a restore from being silently corrupted by
// schema skew, and it runs before anything opens the database. The manifest
// stays put on a mismatch: the operator's next move is to start the image the
// backup names, and it has to be there when they do.
func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
live := seedPanelData(t)
var archive bytes.Buffer
if err := backup.Create(&archive, backup.Params{
DataDir: live,
DBPath: filepath.Join(live, "selfpost.db"),
Version: "9.9.9",
}); err != nil {
t.Fatalf("create backup: %v", err)
}
target := t.TempDir()
extract(t, archive.Bytes(), target)
cfg := panelConfig(t, target)
_, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err == nil {
t.Fatal("the panel booted on a data directory left by another version")
}
for _, want := range []string{"9.9.9", buildinfo.Version} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not name %q, so the operator cannot tell which image to run: %v", want, err)
}
}
if _, statErr := os.Stat(cfg.manifestPath); statErr != nil {
t.Errorf("the manifest was consumed by a refused restore: %v", statErr)
}
}
// seedPanelData builds the /data tree of a panel that has been in use: an
// administrator, a sending domain with an application and one logged message,
// and the daemon state the mail path needs (a DKIM key, the SASL database and
// Postfix's sender map).
func seedPanelData(t *testing.T) string {
t.Helper()
dataDir := t.TempDir()
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(restorePassword), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
if err := st.CreateGlobalUser("admin", string(hash)); err != nil {
t.Fatalf("create user: %v", err)
}
dom, err := st.AddDomain(restoreDomain, "selfpost")
if err != nil {
t.Fatalf("add domain: %v", err)
}
if _, err := st.AddApplication(dom.ID, "shop", store.AddressModeWildcard, nil); err != nil {
t.Fatalf("add application: %v", err)
}
if err := st.InsertQueued(store.SendLogEntry{
QueueID: "4A1B2C3D", Domain: restoreDomain, AppLogin: "shop",
From: "noreply@" + restoreDomain, To: "customer@example.net", Subject: restoreSubject,
}); err != nil {
t.Fatalf("insert send-log row: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
for path, content := range map[string]string{
filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY",
filepath.Join("sasl", "sasldb2"): "SASLDB",
filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n",
filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent",
} {
full := filepath.Join(dataDir, path)
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", full, err)
}
if err := os.WriteFile(full, []byte(content), 0o640); err != nil {
t.Fatalf("write %s: %v", full, err)
}
}
return dataDir
}
// panelConfig resolves the panel's own configuration for a data directory, so
// the test finds the files where the running binary would look for them rather
// than where it put them. Cookies are marked insecure for the same reason the
// e2e stand does it: the test client speaks plain HTTP.
func panelConfig(t *testing.T, dataDir string) config {
t.Helper()
t.Setenv("SELFPOST_DATA_DIR", dataDir)
t.Setenv("PANEL_COOKIE_SECURE", "false")
t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru")
// MAIL_LOG's default is an absolute path, not one derived from the data
// directory; without this the panel would read the host's /data.
t.Setenv("MAIL_LOG", filepath.Join(dataDir, "log", "mail.log"))
return loadConfig()
}
// bootPanel performs the startup sequence run() performs, in the same order,
// and returns the panel's HTTP handler.
func bootPanel(t *testing.T, dataDir string) http.Handler {
t.Helper()
cfg := panelConfig(t, dataDir)
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil {
t.Fatalf("the panel refused to start on %s: %v", dataDir, err)
}
if restored {
if _, err := os.Stat(cfg.manifestPath); err == nil {
t.Errorf("the restore manifest was not consumed, so the next start is gated by it too")
}
}
st, err := store.Open(cfg.dbPath)
if err != nil {
t.Fatalf("open the restored database: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if restored {
if err := resyncAfterRestore(cfg, st, true); err != nil {
t.Fatalf("resync after restore: %v", err)
}
}
panel, err := newPanel(cfg, st)
if err != nil {
t.Fatalf("build the panel: %v", err)
}
if err := panel.Start(); err != nil {
t.Fatalf("start the panel: %v", err)
}
return panel.Handler()
}
// signIn signs in as the seeded administrator and returns the session cookie.
func signIn(t *testing.T, h http.Handler) *http.Cookie {
t.Helper()
form := url.Values{"username": {"admin"}, "password": {restorePassword}}
rec := request(t, h, http.MethodPost, "/login", strings.NewReader(form.Encode()), nil)
if rec.Code != http.StatusSeeOther {
t.Fatalf("sign in = %d, want 303:\n%s", rec.Code, rec.Body.String())
}
cookies := rec.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("sign in issued no session cookie")
}
return cookies[0]
}
// downloadBackup takes a backup through the panel's own /backup route, the way
// the operator does. An empty password downloads the plain archive; otherwise
// the response is a .spbk envelope, which is decrypted here.
func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password string) []byte {
t.Helper()
form := url.Values{}
if password != "" {
form.Set("encrypt", "1")
form.Set("password", password)
form.Set("password_confirm", password)
}
rec := request(t, h, http.MethodPost, "/backup", strings.NewReader(form.Encode()), session)
if rec.Code != http.StatusOK {
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("Cache-Control = %q; an archive of every secret on the server must not be cached", got)
}
body := rec.Body.Bytes()
if password == "" {
if secretfile.HasMagic(body) {
t.Fatal("an unencrypted download came back as an envelope")
}
return body
}
if !secretfile.HasMagic(body) {
t.Fatal("the download is not an encrypted envelope, so the archive left the panel in the clear")
}
r, err := secretfile.NewReader(bytes.NewReader(body), password)
if err != nil {
t.Fatalf("open the encrypted backup: %v", err)
}
plain, err := io.ReadAll(r)
if err != nil {
t.Fatalf("decrypt the backup: %v", err)
}
return plain
}
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the /data
// bind mount before the image is started.
func extract(t *testing.T, archive []byte, dir string) {
t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
t.Fatalf("the download is not a gzip stream: %v", err)
}
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("read the archive: %v", err)
}
name := filepath.Clean(filepath.FromSlash(hdr.Name))
if strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
t.Fatalf("the archive escapes the directory it is extracted into: %q", hdr.Name)
}
path := filepath.Join(dir, name)
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(path, hdr.FileInfo().Mode().Perm()); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode().Perm())
if err != nil {
t.Fatalf("create %s: %v", path, err)
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
t.Fatalf("write %s: %v", path, err)
}
if err := f.Close(); err != nil {
t.Fatalf("close %s: %v", path, err)
}
}
}
}
// getPage performs a GET and returns the body, failing on any non-200.
func getPage(t *testing.T, h http.Handler, target string, session *http.Cookie) string {
t.Helper()
rec := request(t, h, http.MethodGet, target, nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d, want 200:\n%s", target, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
// request drives the panel's real handler chain, including the origin check,
// with the headers a browser on the panel's own page would send.
func request(t *testing.T, h http.Handler, method, target string, body io.Reader, session *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, "http://mail.example.ru"+target, body)
req.Host = "mail.example.ru"
if method == http.MethodPost {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Sec-Fetch-Site", "same-origin")
}
if session != nil {
req.AddCookie(session)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}