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
+49 -4
View File
@@ -18,7 +18,46 @@ Migration `0010` (indexes only).
domain's rows; a page of a domain administrator's log sorted the domain's
whole log. Migration `0010` adds four indexes, and a test now holds each
of these statements to its index (audit 2026-10-12, M2).
- **`selfpost-backup` with a wrong `SELFPOST_DB_PATH` made a backup of
nothing.** SQLite created an empty database where the path pointed, and
the archive of it passed as a backup - one that restores a server offering
its first-run setup while it holds live DKIM keys. A database that is not
there is now refused, and the snapshot opens it read-only (audit
2026-10-12, M3).
- **The CLI encrypted a backup with any password.** It now holds the
password to the panel's floor of 12 characters (audit 2026-10-12, M5).
Decrypting takes any password, as before.
- **`selfpost-backup -o FILE` left a half-written file** when it failed,
and `-decrypt -o FILE` with a wrong password emptied a file that may have
been a good archive. The output is written beside its name and takes it
only when whole; `-decrypt` checks the password on the first block before
it touches the output (audit 2026-10-12, L1).
- **A backup left links out without a word, and an empty `certs/` passed.**
An entry a backup does not carry - a link under `data/`, a link under
`certs/` to a directory - is now named in a warning (the CLI's standard
error, the panel's log), and a `certs/` with no file in it is refused: the
archive would restore a server without its certificate (audit
2026-10-12, L2).
- **`TRUSTED_PROXY_CIDR=::ffff:10.0.0.1` trusted the wrong addresses.** A
bare IPv4-mapped address written as IPv6 was given "/32", which made it
`::/32`: every address beginning with 32 zero bits, `::1` among them, and
not the proxy meant. A bare address is now one host however it is written
(audit 2026-10-12, L4).
- **An encrypted file could make the panel allocate 4 GiB before its
password was checked.** The key-derivation parameters an encrypted backup
or export carries were capped so loosely that a crafted header, uploaded
to *Import a domain*, asked scrypt for 4 GiB and sixteen times the work.
A header may now ask for 128 MiB at most - four times what SelfPost
writes, with the rest of scrypt's memory under 32 KiB - and is refused as
damaged otherwise. Bytes after an envelope's
last block, such as a second file appended to it, are refused too (audit
2026-10-12, M4, L3).
- **A power loss could bring back a map the panel had just replaced.** The
files the panel writes for Postfix and OpenDKIM were synced, but not the
directory their rename changed, so the old file could return while the
database said otherwise. The directory is synced after the rename now, as
`sasldb2` has been since 2.5.2, and the CLI's `-o` file too (audit
2026-10-12, L7).
- **A restore whose first start failed never ran its Resync.** The panel
removed the restore manifest as soon as it had checked the version, before
it opened the database and regenerated the mail maps from it; a start that
@@ -31,9 +70,15 @@ Migration `0010` (indexes only).
there. Every archive now ends with `data/contents.json`, the size and
SHA-256 of each file under `data/`, and the first start after a restore
compares `/data` with it before it opens anything: an archive unpacked in
part, or a file that differs, is refused with a message that names it, and
the directory is left as it was (guide § Restore). A damaged or cut domain
export was already refused before anything is created; a test now says so.
part, a file that differs, or a database journal file left beside the
database from before, is refused with a message that names it, and the
directory is left as it was (guide § Restore). The entrypoint now hands
every map under `data/postfix` to the panel, whatever its name - a restore
unpacked as root left the DMARC maps root's, and the comparison could not
read them. A damaged or cut domain export was already refused before
anything is created; a test now says so.
- **The guide's in-place restore moved `.`**, which `mv` refuses; it now
steps out of the project directory first.
## [2.5.2] - 2026-10-15