audit 3: backup CLI, envelope budget, directory sync, docs; findings of the reviews
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>
This commit is contained in:
+44
-15
@@ -12,6 +12,8 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/atomicfile"
|
||||
)
|
||||
|
||||
// FormatContents identifies the record of what an archive's data directory
|
||||
@@ -93,34 +95,49 @@ func errUnpackedPart(detail string) error {
|
||||
}
|
||||
|
||||
// checkContents compares the data directory with the archive's record, once
|
||||
// per restore (see contentsCheckedName).
|
||||
// per restore (see contentsCheckedName). A record unpacked anew wins over the
|
||||
// mark of an earlier check: the archive was unpacked again, and that is what
|
||||
// the start runs on (review of audit stage 3).
|
||||
func checkContents(dataDir string) error {
|
||||
checked := filepath.Join(dataDir, contentsCheckedName)
|
||||
if c, err := readContents(checked); err == nil {
|
||||
// A start before this one checked the files. It is the same restore
|
||||
// when the manifest is still the one that record holds; a different
|
||||
// archive unpacked over the directory since is checked anew.
|
||||
if verifyFiles(dataDir, c, DataArchivePrefix+ManifestName) == nil {
|
||||
return nil
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(dataDir, ContentsName)
|
||||
checked := filepath.Join(dataDir, contentsCheckedName)
|
||||
c, err := readContents(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
prev, perr := readContents(checked)
|
||||
if perr == nil && verifyFiles(dataDir, prev, DataArchivePrefix+ManifestName) == nil {
|
||||
// A start before this one checked the files, and the manifest is
|
||||
// still the one that record holds: the same restore, whose
|
||||
// files that start may have changed since.
|
||||
return nil
|
||||
}
|
||||
if perr != nil && !errors.Is(perr, os.ErrNotExist) {
|
||||
return perr
|
||||
}
|
||||
return errUnpackedPart(DataArchivePrefix + ContentsName + ", its last entry, is not there")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// No backup carries the database's journal files. One beside the
|
||||
// restored database is left from the database the directory held
|
||||
// before, and opening this one would take it up: the first connection
|
||||
// after a crash recovers from the WAL, and a checkpoint "takes content
|
||||
// from the WAL file and transfers it back into the original database
|
||||
// file" (https://sqlite.org/wal.html § 9, § 2.2).
|
||||
for _, side := range []string{"-wal", "-shm", "-journal"} {
|
||||
if _, err := os.Lstat(filepath.Join(dataDir, "selfpost.db"+side)); err == nil {
|
||||
return errUnpackedPart("selfpost.db" + side + " is beside the database, and no archive holds one - it is left from what was in the directory before")
|
||||
}
|
||||
}
|
||||
if err := verifyFiles(dataDir, c, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(path, checked); err != nil {
|
||||
return fmt.Errorf("backup: mark the restored files checked: %w", err)
|
||||
}
|
||||
// The start goes on to change the files; the mark must not be lost to a
|
||||
// power cut after that, or the next start compares changed files.
|
||||
_ = atomicfile.SyncDir(dataDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -151,15 +168,27 @@ func verifyFiles(dataDir string, c Contents, only string) error {
|
||||
if !ok || rel == "" || path.Clean(rel) != rel || rel == ".." || strings.HasPrefix(rel, "../") || path.IsAbs(rel) {
|
||||
return errUnpackedPart("the record names " + f.Name + ", which is not a file of the data directory")
|
||||
}
|
||||
file, err := os.Open(filepath.Join(dataDir, filepath.FromSlash(rel)))
|
||||
// The archive holds regular files only (archive.writeFile); a link
|
||||
// or a pipe under the name is not followed or read - a crafted
|
||||
// archive must not make the start read a file outside /data, or
|
||||
// wait on a pipe (security review of audit stage 3).
|
||||
full := filepath.Join(dataDir, filepath.FromSlash(rel))
|
||||
info, err := os.Lstat(full)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return errUnpackedPart(f.Name + " is missing")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: check %s: %w", f.Name, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() != f.Size {
|
||||
return errUnpackedPart(f.Name + " is not the file the archive holds")
|
||||
}
|
||||
file, err := os.Open(full)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: check %s: %w", f.Name, err)
|
||||
}
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(h, file)
|
||||
n, err := io.Copy(h, io.LimitReader(file, f.Size+1))
|
||||
file.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: check %s: %w", f.Name, err)
|
||||
|
||||
Reference in New Issue
Block a user