Steps 3-6 of stage 3 and what the two reviews found in steps 1-6, in one commit: the review fixes sit in the same hunks. Step 3, CLI backup (M3, M5, L1, L2; L4 of the stage's list): - backup.snapshotDB refuses a database that is not there and opens the source mode=ro (measured: mode=ro creates no file). - selfpost-backup holds an encrypting password to validate.MinSecretFilePasswordLen; -o of both directions writes a temporary file beside the name and renames it whole; -decrypt peeks the first chunk (password and type authenticated) before the output exists. - what a backup leaves out (a link under data/, a link under certs/ to a directory, a socket...) is told to OnWarn; certs/ with no file is backup.ErrNoCerts, refused by Check and Create, named by the panel. - parseTrustedProxies: a bare address takes /128 when written with a colon (::ffff:10.0.0.1/32 was ::/32). Step 4, secretfile (M4, L3): scrypt's V at most 128 MiB, r <= 64, p <= 2; bytes after the last chunk are ErrCorrupt. Step 5 (L7): atomicfile.SyncDir after the rename, used by Write, replaceSASLDB and the CLI; atomicfile_test.go. Step 6: security.md and guide on backup and restore. Reviews (technical, security): - H1: a restore of a server with DMARC on was refused on every start - the archive records no owner, tar as root left the DMARC maps root's, and the entrypoint only chowned the maps it named. It now hands every file beside queue/ in /data/postfix to panel:selfpost 0640; e2e writes an extra map before the backup and finds it panel:selfpost 640 after a root unpack. - the comparison reads regular files only (Lstat, size, LimitReader); a record unpacked again wins over the earlier check's mark; ConsumeRestore removes both; the mark is synced; a journal file beside a freshly unpacked database is refused. - AttachRefusalLine refuses an empty line as the old statement did; skipped entries named in words; -decrypt of an empty payload is no failure; guide wording on the shell's '>' and a killed CLI; the in-place restore's 'mv .' (older than this stage) fixed. Tested on the test server: go vet + go test of 25 packages, make e2e (all pass, 643 s) on this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
68 lines
2.1 KiB
Go
68 lines
2.1 KiB
Go
// Package atomicfile writes a file so that readers see the old file or the
|
|
// new one, whole, and a power loss after Write returns keeps the new one.
|
|
package atomicfile
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// Write writes data to path via a temp file in the same directory followed by
|
|
// a rename, so readers only ever see the complete old or new file. The file
|
|
// is synced before the rename and the directory after it: the rename is a
|
|
// change of the directory, and without its own sync a power loss may bring
|
|
// the old file back (fsync(2): syncing a file "does not necessarily ensure
|
|
// that the entry in the directory containing the file has also reached
|
|
// disk"; audit 2026-10-12, L7). Every map the panel writes for Postfix and
|
|
// OpenDKIM goes through here.
|
|
func Write(path string, data []byte, perm os.FileMode) error {
|
|
dir := filepath.Dir(path)
|
|
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
|
if err != nil {
|
|
return fmt.Errorf("create temp in %s: %w", dir, err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
cleanup := true
|
|
defer func() {
|
|
if cleanup {
|
|
_ = os.Remove(tmpName)
|
|
}
|
|
}()
|
|
|
|
if _, err := tmp.Write(data); err != nil {
|
|
tmp.Close()
|
|
return fmt.Errorf("write temp %s: %w", tmpName, err)
|
|
}
|
|
if err := tmp.Chmod(perm); err != nil {
|
|
tmp.Close()
|
|
return fmt.Errorf("chmod temp %s: %w", tmpName, err)
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
tmp.Close()
|
|
return fmt.Errorf("sync temp %s: %w", tmpName, err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("close temp %s: %w", tmpName, err)
|
|
}
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
return fmt.Errorf("rename %s -> %s: %w", tmpName, path, err)
|
|
}
|
|
cleanup = false
|
|
_ = SyncDir(dir)
|
|
return nil
|
|
}
|
|
|
|
// SyncDir makes a rename in dir durable. It is best effort for its callers:
|
|
// the new file is in place and readable either way, and not every system
|
|
// syncs a directory - Go on Windows cannot open one for it - so a failure
|
|
// leaves the change as durable as it was before this existed.
|
|
func SyncDir(dir string) error {
|
|
d, err := os.Open(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer d.Close()
|
|
return d.Sync()
|
|
}
|