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:
+101
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user