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