Phase 9: full backup/restore + domain export/import (spec 7.5, 11.6)

Full server backup (spec 7.5.A): internal/backup produces a tar.gz of all of
/data — a consistent SQLite snapshot via VACUUM INTO, DKIM keys, sasldb2 and a
version manifest; TLS certs (tls/) and the Postfix queue are excluded. Two equal
paths: the panel button (POST /backup, no-store) and the selfpost-backup CLI via
docker exec (spec 11.6). CheckRestore runs before store.Open: a manifest version
mismatch refuses to boot with the image tag to use; a match consumes the
manifest so it only guards the first post-restore boot. Restore is not a
separate branch — Postfix/OpenDKIM regenerate from the restored SQLite as on any
start.

Domain export/import (spec 7.5.B): DomainExport carries the DKIM private key and
each application's working password. SASL secrets are read from sasldb2 via
db_dump (the userPassword property is plaintext) and, on import, re-keyed under
the local realm with saslpasswd2 — so credentials keep working on an instance
with a different hostname, with no DKIM DNS change. Import validates and rolls
back atomically on any failure. db-util (db_dump) is now an explicit image dep.

Verified on the server (selfpost:p9): gofmt/vet/test green; container e2e for
cross-realm domain export/import (SMTP AUTH 235 under the new realm), CLI and
panel backups, same-version restore, and version-mismatch refusal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 22:33:07 +03:00
parent 223f3cdc42
commit ae42450ec1
22 changed files with 1409 additions and 22 deletions
+75
View File
@@ -1,6 +1,9 @@
package app
import (
"encoding/hex"
"errors"
"fmt"
"strings"
"testing"
)
@@ -62,6 +65,78 @@ func TestSASLDeleteArgs(t *testing.T) {
}
}
// makeDump builds a db_dump byte-value document from key/value byte pairs, the
// same shape `db_dump <sasldb2>` emits.
func makeDump(pairs [][2][]byte) []byte {
var b strings.Builder
b.WriteString("VERSION=3\nformat=bytevalue\ntype=hash\nHEADER=END\n")
for _, p := range pairs {
fmt.Fprintf(&b, " %s\n", hex.EncodeToString(p[0]))
fmt.Fprintf(&b, " %s\n", hex.EncodeToString(p[1]))
}
b.WriteString("DATA=END\n")
return []byte(b.String())
}
func saslKey(login, realm, prop string) []byte {
return []byte(login + "\x00" + realm + "\x00" + prop)
}
func TestSecretExtractsPassword(t *testing.T) {
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
s.dump = func(path string) ([]byte, error) {
if path != "/data/sasl/sasldb2" {
t.Errorf("dump path = %q", path)
}
return makeDump([][2][]byte{
{saslKey("other", "mail.example.com", "userPassword"), []byte("otherpw")},
{saslKey("alerts", "mail.example.com", "userPassword"), []byte("hunter2-pass")},
}), nil
}
got, err := s.Secret("alerts")
if err != nil {
t.Fatalf("Secret: %v", err)
}
if got != "hunter2-pass" {
t.Errorf("Secret = %q, want %q", got, "hunter2-pass")
}
}
func TestSecretRealmMismatchNotFound(t *testing.T) {
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
s.dump = func(string) ([]byte, error) {
// Same login but a different realm must not match.
return makeDump([][2][]byte{
{saslKey("alerts", "other.host", "userPassword"), []byte("hunter2")},
}), nil
}
if _, err := s.Secret("alerts"); !errors.Is(err, ErrSecretNotFound) {
t.Errorf("Secret err = %v, want ErrSecretNotFound", err)
}
}
func TestSecretMissingLoginNotFound(t *testing.T) {
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
s.dump = func(string) ([]byte, error) {
return makeDump(nil), nil
}
if _, err := s.Secret("ghost"); !errors.Is(err, ErrSecretNotFound) {
t.Errorf("Secret err = %v, want ErrSecretNotFound", err)
}
}
func TestSecretRejectsInvalidLoginBeforeDump(t *testing.T) {
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
called := false
s.dump = func(string) ([]byte, error) { called = true; return nil, nil }
if _, err := s.Secret("bad login"); err == nil {
t.Error("Secret accepted invalid login")
}
if called {
t.Error("db_dump invoked for an invalid login")
}
}
func TestSASLRejectsInvalidLoginBeforeExec(t *testing.T) {
s, fr := newFakeSASL()
if err := s.Set("bad login", "pw"); err == nil {