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>
Migration 0010 (DDL only; 0009 went to stage 2b). EXPLAIN QUERY PLAN of
every send_log statement showed, beyond M2 as written (stage 2 had already
moved the by-domain counts onto (domain_id, created_at)):
- the tailer's sweep of open rows (every 5 min) and its count scanned the
table: partial idx_send_log_open (created_at) WHERE status IN ('queued',
'deferred'), the statuses literal in the statements, ORDER BY +id (else
SQLite walks the table in id order to skip a sort);
- AttachRefusalLine's NOT EXISTS (... refusal_line = ?1) scanned the table
for every refusal line: partial idx_send_log_refusal_line, the query
carrying the index's WHERE; its row found by idx_send_log_rejected
(status literal, +queue_id) instead of every refusal without a queue id;
- an application's level-2 count went through its domain's index:
+domain_id in appByLogin;
- a page of one domain's or one application's log sorted all of it:
idx_send_log_domain_id, idx_send_log_app_login (id order; several domains
still sort, top-N). idx_send_log_domain stays for the global filter.
The statements are constants and builders (countMessagesQuery,
peakPerHourQuery, listOpenQuery, countOpenQuery, sendLogPageQuery,
attachRefusalLineQuery, limitRefusalsQuery) and
TestSendLogQueriesUseTheirIndexes explains exactly those: the index each
must use, no SCAN of send_log, no sort where none is wanted; the literal
statuses checked against the constants. It replaces the hand-written plan
check of TestLimitRefusalsSince.
Tested on the test server: go vet + go test of 24 packages, make e2e (all
pass) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>