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:
mix
2026-10-15 10:38:39 +03:00
co-authored by Claude Opus 5.5
parent 508ac96c79
commit 242515c305
24 changed files with 901 additions and 90 deletions
+14
View File
@@ -41,6 +41,16 @@ func testFullBackupRestore(t *testing.T, sc *scenario) {
t.Fatal(err)
}
// A map under data/postfix by a name the entrypoint does not list - the
// DMARC maps were such, with DMARC on (review of audit stage 3, H1). The
// archive records no owner, and the unpack below runs as root, as an
// operator's tar does: the entrypoint has to hand the file back to the
// panel, which reads every recorded file on the first start.
if out, err := h.execIn("selfpost", "sh", "-c",
"echo 'x OK' > /data/postfix/e2e_extra_map && chown panel:selfpost /data/postfix/e2e_extra_map && chmod 0640 /data/postfix/e2e_extra_map"); err != nil {
t.Fatalf("write an extra map: %v\n%s", err, out)
}
resp, archive, err := sc.panel.postForm("/server/backup", url.Values{})
if err != nil {
t.Fatalf("full backup: %v", err)
@@ -104,6 +114,10 @@ func testFullBackupRestore(t *testing.T, sc *scenario) {
t.Fatalf("%v\n%s", err, logTail(h.logs("selfpost"), "backup"))
}
if out, _ := h.execIn("selfpost", "stat", "-c", "%U:%G %a", "/data/postfix/e2e_extra_map"); strings.TrimSpace(out) != "panel:selfpost 640" {
t.Errorf("the extra map after the restore is %q, want panel:selfpost 640", strings.TrimSpace(out))
}
p, err := newPanelClient()
if err != nil {
t.Fatal(err)