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
+101 -1
View File
@@ -1,7 +1,9 @@
package app
import (
"bufio"
"bytes"
"encoding/hex"
"fmt"
"os/exec"
"strings"
@@ -18,13 +20,17 @@ type SASLDB struct {
// run executes saslpasswd2. It is a field so tests can substitute a fake;
// the default shells out to the real binary via runSaslpasswd2.
run func(args []string, stdin []byte) error
// dump reads the raw sasldb2 as db_dump key/value pairs (Berkeley DB). It is
// a field so tests can substitute a fake; the default runs db_dump.
dump func(path string) ([]byte, error)
}
// NewSASLDB builds a manager for the sasldb2 at path with the given realm. The
// realm should match SELFPOST_HOSTNAME so the account identity lines up with
// Postfix's SASL configuration in Phase 5.
func NewSASLDB(path, realm string) *SASLDB {
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2}
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB}
}
// Set creates or updates an application's SASL account with the given password
@@ -65,6 +71,88 @@ func (s *SASLDB) Delete(login string) error {
return nil
}
// ErrSecretNotFound is returned by Secret when the sasldb2 has no password entry
// for the login under this realm.
var ErrSecretNotFound = fmt.Errorf("sasl secret not found")
// Secret returns an application's stored password so it can be carried in a
// domain export and re-created verbatim on another instance (spec 7.5.B). This
// is possible because sasldb2 keeps the SASL secret in a password-equivalent
// form (the plaintext userPassword property, to serve challenge-response
// mechanisms) — unlike the admin's one-way bcrypt hash (spec 7.6). The value is
// realm-independent, so the importer can re-key it under its own realm.
//
// It reads the database with db_dump (Berkeley DB), passing only our own file
// path as a fixed argument (no shell, no user input — spec 7.6.3), and returns
// ErrSecretNotFound if the login has no entry.
func (s *SASLDB) Secret(login string) (string, error) {
if err := validateLogin(login); err != nil {
return "", err
}
out, err := s.dump(s.path)
if err != nil {
return "", fmt.Errorf("read sasldb2 for %q: %w", login, err)
}
secret, ok, err := parseSASLSecret(out, login, s.realm)
if err != nil {
return "", fmt.Errorf("parse sasldb2 for %q: %w", login, err)
}
if !ok {
return "", fmt.Errorf("login %q: %w", login, ErrSecretNotFound)
}
return secret, nil
}
// parseSASLSecret scans db_dump's byte-value output for the userPassword entry
// keyed by (login, realm). sasldb2 keys are NUL-separated tuples
// "<login>\0<realm>\0<property>"; the matching value is the stored password.
func parseSASLSecret(dump []byte, login, realm string) (string, bool, error) {
sc := bufio.NewScanner(bytes.NewReader(dump))
// sasldb2 records are tiny, but raise the line cap so a long hex line is
// never silently truncated.
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
inData := false
var keyBytes []byte
haveKey := false
for sc.Scan() {
line := sc.Text()
if !inData {
if line == "HEADER=END" {
inData = true
}
continue
}
if line == "DATA=END" {
break
}
// Each data line is a single leading space followed by hex.
hexStr := strings.TrimPrefix(line, " ")
raw, err := hex.DecodeString(hexStr)
if err != nil {
return "", false, fmt.Errorf("bad db_dump hex line: %w", err)
}
if !haveKey {
keyBytes = raw
haveKey = true
continue
}
// raw is the value for keyBytes.
haveKey = false
parts := bytes.Split(keyBytes, []byte{0})
if len(parts) != 3 {
continue
}
if string(parts[0]) == login && string(parts[1]) == realm && string(parts[2]) == "userPassword" {
return string(raw), true, nil
}
}
if err := sc.Err(); err != nil {
return "", false, err
}
return "", false, nil
}
// runSaslpasswd2 executes the real saslpasswd2 with the given arguments and
// stdin. Arguments are passed as a fixed argv (no shell), so no user input is
// ever interpreted as a command (spec 7.6.3).
@@ -79,3 +167,15 @@ func runSaslpasswd2(args []string, stdin []byte) error {
}
return nil
}
// dumpSASLDB runs db_dump to export the sasldb2 as key/value hex pairs. The path
// is our own sasldb2 file (never user input) and is passed as a fixed argument
// with no shell (spec 7.6.3).
func dumpSASLDB(path string) ([]byte, error) {
cmd := exec.Command("db_dump", path)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("db_dump: %w: %s", err, strings.TrimSpace(string(out)))
}
return out, nil
}
+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 {
+32
View File
@@ -92,6 +92,38 @@ func (s *Service) rollbackCreate(id int64, login string) {
_, _ = s.store.DeleteApplication(id)
}
// ImportApplication re-creates an application from a domain-export file (spec
// 7.5.B): it validates the login and (in list mode) that every address belongs
// to the domain, inserts the registry row and writes the SASL account with the
// imported password verbatim, re-keyed under this instance's realm so the
// credential keeps working without regeneration. It deliberately does not
// rebuild the sender map — the caller (domain import) does that once after all
// applications are in — and returns store.ErrLoginExists if the login collides.
func (s *Service) ImportApplication(domainID int64, login, mode string, rawAddresses []string, password string) error {
addresses, err := s.validateForDomain(domainID, login, mode, rawAddresses)
if err != nil {
return err
}
if err := validateImportedPassword(password); err != nil {
return err
}
a, err := s.store.AddApplication(domainID, login, mode, addresses)
if err != nil {
return err // ErrLoginExists surfaces to the caller as a friendly message
}
if err := s.sasl.Set(login, password); err != nil {
_, _ = s.store.DeleteApplication(a.ID)
return err
}
return nil
}
// Secret returns an application's stored password for a domain export (spec
// 7.5.B). See SASLDB.Secret for why this is possible and safe.
func (s *Service) Secret(login string) (string, error) {
return s.sasl.Secret(login)
}
// UpdateMode switches an application's address mode / list and rebuilds the
// sender map (spec 7.2.7). The login and password are untouched. Addresses are
// re-validated against the application's domain.
+46
View File
@@ -221,6 +221,52 @@ func TestServiceRegeneratePassword(t *testing.T) {
}
}
func TestImportApplicationWritesRowAndSASL(t *testing.T) {
svc, st, rec, maps := newServiceHarness(t)
d := addDomain(t, st, "example.com")
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeList,
[]string{"a@example.com"}, "imported-pw"); err != nil {
t.Fatalf("ImportApplication: %v", err)
}
// Registry row and SASL account written with the imported password verbatim.
apps, _ := st.ListApplicationsByDomain(d.ID)
if len(apps) != 1 || apps[0].Login != "mailer" {
t.Fatalf("apps = %+v", apps)
}
if rec.set["mailer"] != "imported-pw" {
t.Errorf("SASL password = %q, want the imported one", rec.set["mailer"])
}
// Import does not rebuild the sender map itself (the caller batches that).
if maps.calls != 0 {
t.Errorf("ImportApplication rebuilt the map %d times, want 0", maps.calls)
}
}
func TestImportApplicationRejectsBadInput(t *testing.T) {
svc, st, rec, _ := newServiceHarness(t)
d := addDomain(t, st, "example.com")
// Empty password.
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeWildcard, nil, ""); err == nil {
t.Error("accepted empty imported password")
}
// Password with an embedded newline would truncate on the saslpasswd2 stdin.
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeWildcard, nil, "line1\nline2"); err == nil {
t.Error("accepted password with control characters")
}
// Cross-domain address.
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeList, []string{"x@evil.com"}, "pw"); err == nil {
t.Error("accepted cross-domain address")
}
if apps, _ := st.ListApplicationsByDomain(d.ID); len(apps) != 0 {
t.Errorf("rows persisted despite validation failure: %+v", apps)
}
if len(rec.set) != 0 {
t.Errorf("SASL accounts written despite validation failure: %v", rec.set)
}
}
func TestServicePurgeDomainSASL(t *testing.T) {
svc, st, rec, _ := newServiceHarness(t)
d := addDomain(t, st, "example.com")
+20
View File
@@ -35,6 +35,26 @@ func validateLogin(login string) error {
return nil
}
// validateImportedPassword guards a password taken from a domain-export file
// (spec 7.5.B) before it is written to sasldb2. Our own exports carry base64url
// passwords, but the file is untrusted input, so we reject an empty value or one
// containing control characters — saslpasswd2 reads the passphrase from stdin
// and a newline would silently truncate it (spec 7.6.2).
func validateImportedPassword(password string) error {
if password == "" {
return fmt.Errorf("imported application password is empty")
}
if len(password) > 1024 {
return fmt.Errorf("imported application password is too long")
}
for _, r := range password {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("imported application password contains control characters")
}
}
return nil
}
// validateAddressMode checks the submitted mode is one of the two known values.
func validateAddressMode(mode string) error {
if mode != store.AddressModeWildcard && mode != store.AddressModeList {