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
+10 -3
View File
@@ -265,10 +265,17 @@ func parseTrustedProxies(raw string) []*net.IPNet {
continue
}
if !strings.Contains(cidr, "/") {
if ip := net.ParseIP(cidr); ip != nil && ip.To4() != nil {
cidr += "/32"
} else {
// The prefix length goes with how the address is written: an
// IPv4-mapped address written as IPv6 (::ffff:10.0.0.1) is a
// 128-bit address, and "/32" on it is ::/32 - every address
// whose first 32 bits are zero (::1 among them) trusted, and the
// host meant not at all (audit 2026-10-12, L4). Go 1.26
// net.IPNet.Contains compares an IPv4 client with the mapped
// /128 as with 10.0.0.1/32 (net/ip.go, networkNumberAndMask).
if strings.Contains(cidr, ":") {
cidr += "/128"
} else {
cidr += "/32"
}
}
_, n, err := net.ParseCIDR(cidr)
+36
View File
@@ -0,0 +1,36 @@
package main
import (
"net"
"testing"
)
// A bare address is one host however it is written; an IPv4-mapped one in
// IPv6 notation used to become ::/32 (audit 2026-10-12, L4).
func TestParseTrustedProxiesTakesABareAddressAsOneHost(t *testing.T) {
for _, c := range []struct {
entry string
trusts []string
refuses []string
}{
{"10.0.0.1", []string{"10.0.0.1"}, []string{"10.0.0.2"}},
{"::ffff:10.0.0.1", []string{"10.0.0.1", "::ffff:10.0.0.1"}, []string{"10.0.0.2", "::1", "0:0:1::1"}},
{"2001:db8::1", []string{"2001:db8::1"}, []string{"2001:db8::2"}},
{"10.0.0.0/8", []string{"10.1.2.3"}, []string{"11.0.0.1"}},
} {
nets := parseTrustedProxies(c.entry)
if len(nets) != 1 {
t.Fatalf("%q parsed as %v", c.entry, nets)
}
for _, ip := range c.trusts {
if !nets[0].Contains(net.ParseIP(ip)) {
t.Errorf("%q (%v) does not trust %s", c.entry, nets[0], ip)
}
}
for _, ip := range c.refuses {
if nets[0].Contains(net.ParseIP(ip)) {
t.Errorf("%q (%v) trusts %s", c.entry, nets[0], ip)
}
}
}
}