build, deploy, inbound, dmarc, backup, e2e: cite the source of what they rely on (assumptions part C)

Comments only, no behaviour changed. Each main.cf/master.cf parameter the
build sets, the wrapper's and the reload hook's reliance on Postfix 3.7.11
(postfix-script, master.5, postconf.5, cleanup_init.c, smtpd_peer.c,
README_FILES), supervisor 4.2.5 (process.py, options.py, events.rst),
logrotate 3.21.0, OpenDKIM's config, Cyrus SASL's sasldb, rspamd 3.11.1
(milter_headers.lua, multimap, the map watcher), Docker Compose, CoreDNS
and certbot now say where that is written. Measurements already there stay
measurements ("date not recorded" where none was); hand-composed fixtures
say so.

Contradictions left for their own commits: C-P31 cleanup's size limit,
C-P42 master outside supervisor's signal, C-V5/V6 startsecs, C-V10,
C-S3/Q3 sasldb written in place, C-Q4/A1 certbot's symlinks, C-R7 rspamd
skips local clients but the signature routine does not, C-P8, C-R4, C-R12,
C-M7, C-E2, C-K8/K9, C-D1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 19:39:21 +03:00
co-authored by Claude Opus 5.5
parent ad3d834392
commit 599149bf83
39 changed files with 417 additions and 59 deletions
+3
View File
@@ -13,6 +13,9 @@
# replace the base file's list rather than concatenate with it (the default
# merge behaviour for sequences) — otherwise the real 465/587/8080 would stay
# published here too and collide with a prod container on the same host.
# (docs.docker.com/reference/compose-file/merge/, "!override": "fully replace
# an attribute, bypassing the standard merge rules". The page names no
# minimum Compose version; the stand's own Compose is what each run uses.)
services:
selfpost:
+2
View File
@@ -55,6 +55,8 @@ func testDeliveryLogSurvivesRotation(t *testing.T, sc *scenario) {
// The rotation ends with `postfix reload`, which fails while Postfix is
// still starting - and this step follows one that restarts the container.
// (Postfix 3.7.11, conf/postfix-script: "reload" and "status" both test
// "master -t" and exit 1 while master does not hold its pid lock.)
if err := waitFor("Postfix to be running", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
if _, err := h.execIn("selfpost", "postfix", "status"); err != nil {
return false, err
+5 -2
View File
@@ -48,7 +48,9 @@ func writeZone(stageDir string, extra []txtRecord) error {
fmt.Fprintf(&b, "ns IN A 10.77.0.10\n")
// The sink-MX: Postfix's outbound delivery for the recipient domain used by
// every positive-path send resolves here via the domain's implicit MX
// fallback to its own A record (RFC 5321) — no explicit MX record needed.
// fallback to its own A record (RFC 5321 § 5.1: with no MX records the
// domain is treated as having an implicit MX of itself) — no explicit MX
// record needed.
fmt.Fprintf(&b, "sink IN A 10.77.0.11\n")
for _, r := range extra {
fmt.Fprintf(&b, "%s IN TXT %s\n", r.name, chunkTXT(r.value))
@@ -57,7 +59,8 @@ func writeZone(stageDir string, extra []txtRecord) error {
}
// chunkTXT splits a TXT value into <255-byte quoted strings (the DNS
// <character-string> limit), which resolvers concatenate back into one value.
// <character-string> limit, RFC 1035 § 3.3), which a DKIM verifier
// concatenates back into one value (RFC 6376 § 3.6.2.2).
// A DKIM RSA-2048 public key's base64 comfortably exceeds 255 bytes on its
// own, so this is required, not defensive.
func chunkTXT(value string) string {
+2 -1
View File
@@ -17,7 +17,8 @@ func runEntrypoint(t *testing.T, hostnameEnv string) (output string, exitedZero
t.Helper()
dataDir := t.TempDir()
// Entrypoint also chmod 755 /data; mirror that here so a pre-fix image
// still gets a traversable bind mount under Go's 0700 TempDir.
// still gets a traversable bind mount under Go's 0700 TempDir (testing.TempDir
// uses os.MkdirTemp, src/os/tempfile.go: "created with mode 0o700").
if err := os.Chmod(dataDir, 0o755); err != nil {
t.Fatalf("chmod data dir: %v", err)
}
+2
View File
@@ -69,6 +69,8 @@ func verifyDKIM(raw []byte, domain string) error {
// observed for a just-made connection (needed to configure a level-2 rate
// limit's allowed-IPs list — the address a host-published port is seen as
// inside the container depends on Docker's NAT and isn't worth hard-coding).
// Postfix 3.7.11, src/smtpd/smtpd.c, smtpd_service(): msg_info("connect from
// %s", namaddr), namaddr being "name[addr]".
var connectFromPattern = regexp.MustCompile(`connect from [^\[]*\[([0-9a-fA-F.:]+)\]`)
// lastSMTPClientIP reads mail.log inside the selfpost container and returns
+16 -4
View File
@@ -47,7 +47,9 @@ func testLevel2RateLimit(t *testing.T, sc *scenario) {
authLogin: login, authPassword: password,
from: from, to: recipient, subject: "e2e " + uniqueToken("l2-second"), body: "should be rejected",
})
// The milter answers tempfail at MAIL FROM, which Postfix words as below.
// The milter answers tempfail at MAIL FROM, which Postfix words as below
// (Postfix 3.7.11, src/milter/milter8.c, milter8_event(), SMFIR_TEMPFAIL:
// "451 4.7.1 Service unavailable - try again later").
if err := second.refusedAt("MAIL", 451, "4.7.1 Service unavailable"); err != nil {
t.Fatalf("the second message over the level-2 limit: %v", err)
}
@@ -58,7 +60,8 @@ func testLevel2RateLimit(t *testing.T, sc *scenario) {
// introduce the client again, so a second MAIL FROM on the same connection
// used to arrive with no client address and was let through (found by the
// technical review of 2026-10-12; the same hole let an application past its
// client-address list).
// client-address list). Postfix's half: 3.7.11, src/smtpd/smtpd.c,
// smtpd_proto(), calls milter_conn_event() once per connection.
errs := mailTwiceOnOneConnection(login, password, from)
for i, err := range errs {
if err := replyIs(err, 451, "4.7.1 Service unavailable"); err != nil {
@@ -91,7 +94,10 @@ func testSenderLoginMismatch(t *testing.T, sc *scenario) {
})
// Postfix evaluates smtpd_sender_restrictions with smtpd_delay_reject=yes
// (the default): the mismatch is detected at MAIL FROM but the reject is
// only sent back at RCPT TO.
// only sent back at RCPT TO. (Postfix 3.7.11, src/global/mail_params.h,
// DEF_SMTPD_DELAY_REJECT 1; src/smtpd/smtpd_check.c,
// reject_auth_sender_login_mismatch(): 553 5.7.1 "Sender address rejected:
// not owned by user".)
if err := res.refusedAt("RCPT", 553, "5.7.1"); err != nil {
t.Fatalf("a sender the application does not own: %v", err)
}
@@ -103,6 +109,10 @@ func testSenderLoginMismatch(t *testing.T, sc *scenario) {
// testNoAuthRejected is plan C.4 negative check 1: no SASL session, no mail.
// The sender address used here belongs to no registered domain, so the
// rejection is unambiguously about the missing AUTH and not sender ownership.
// The reply: smtps's client restrictions end in "reject" (build/
// postfix-config.sh), Postfix 3.7.11, src/smtpd/smtpd_check.c,
// generic_checks(): var_reject_code (554) "<%s>: %s rejected: Access denied",
// delayed to RCPT by smtpd_delay_reject.
func testNoAuthRejected(t *testing.T, sc *scenario) {
res := attemptSend(sendAttempt{
from: "anyone@unregistered.e2e.test", to: recipient,
@@ -181,7 +191,9 @@ func testSessionSurvivesRestart(t *testing.T, sc *scenario) {
// when the connection was reset because Postfix was still starting - and,
// held to anvil's reply, it turned out the first message had always been
// refused for another reason: the new login was not yet in the sender map an
// smtpd from before the panel's reload held (waitForSenderMap).
// smtpd from before the panel's reload held (waitForSenderMap). (An incident
// on the stand, date not recorded.) Anvil's reply: Postfix 3.7.11,
// src/smtpd/smtpd.c, mail_cmd(): "450 4.7.1 Error: too much mail from %s".
func testLevel1RateLimit(t *testing.T, sc *scenario) {
login, password, err := sc.panel.addApplication(sc.domainID, "l1app", "wildcard", "")
if err != nil {
+2 -1
View File
@@ -59,7 +59,8 @@ func deliverPort25(from, to, message string) sendResult {
// waitForPort25Ready polls port 25 until Postfix greets on it. A bare connect
// is not enough: Docker's proxy accepts on the published port whether or not
// anything listens behind it.
// anything listens behind it (seen on the stand, date not recorded - see
// waitForSMTPSReady).
func waitForPort25Ready(timeout time.Duration) error {
return waitFor("port 25 to greet", timeout, 500*time.Millisecond, func() (bool, error) {
conn, err := net.DialTimeout("tcp", port25Addr, 2*time.Second)
+5 -2
View File
@@ -33,7 +33,8 @@ type port25Case struct {
// ingestService is the dmarc-ingest service as Postfix must read it back.
// flags=, user= and argv= are arguments of pipe(8); set as -o parameters they
// are dropped, and a bare "pipe" dies on every report with "fatal: missing
// user= command-line attribute" - the state of 1.7.0 through 1.9.5.
// user= command-line attribute" - the state of 1.7.0 through 1.9.5 (date not
// recorded; Postfix 3.7.11, src/pipe/pipe.c, get_service_attr()).
const ingestService = "dmarc-ingest unix - n n - 1 pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}"
var port25Cases = []port25Case{
@@ -145,7 +146,9 @@ func checkPort25ConfigCombinations(s *stack) error {
// a service line on whitespace, so a value with a space in it is
// cut at the space and the rest becomes a stray smtpd argument
// ("fatal: unexpected command-line argument", port 25 dead while
// `postfix check` stays silent). Ask Postfix itself.
// `postfix check` stays silent). Ask Postfix itself. (Seen in 1.9.1,
// date not recorded; Postfix 3.7.11, man/man5/master.5, "-o
// name=value (short form)", NOTE 2.)
seen, err := s.execIn("selfpost", "postconf", "-Ph", "smtp/inet/"+c.param)
if err != nil {
return fmt.Errorf("%s: postconf -P smtp/inet/%s: %v\n%s", tc.name, c.param, err, seen)
+8 -1
View File
@@ -58,6 +58,7 @@ func testEnablePort25Features(t *testing.T, sc *scenario) {
const reportReporter = "reporter.e2e.test"
// aggregateXML is a DMARC aggregate report on domain, as a receiver sends it.
// Composed by hand in the shape of RFC 7489, Appendix C; no report was copied.
func aggregateXML(domain, reportID string) string {
return `<?xml version="1.0"?>
<feedback>
@@ -88,7 +89,9 @@ func aggregateXML(domain, reportID string) string {
}
// reportMessage is the mail a receiver sends a report in: one gzip attachment,
// base64 in 76-column lines.
// base64 in 76-column lines. Composed by hand: RFC 7489 § 7.2.1.1
// (application/gzip, a .xml.gz name), RFC 2045 § 6.8 (base64 lines of at most
// 76 characters).
func reportMessage(t *testing.T, to, xml string) string {
t.Helper()
var gz bytes.Buffer
@@ -344,6 +347,10 @@ func testInboundRelay(t *testing.T, sc *scenario) {
t.Fatalf("the Inbound log holds %d rows, want the relayed message alone:\n%s", len(rows), strings.Join(rows, "\n"))
}
// Postfix 3.7.11, src/smtpd/smtpd_check.c: "User unknown in relay recipient
// table" (check_rcpt_maps(); unknown_relay_recipient_reject_code,
// DEF_RELAY_RCPT_CODE 550) and "Relay access denied"
// (reject_unauth_destination(); DEF_RELAY_CODE 554).
for _, c := range []struct {
to string
code int
+4 -1
View File
@@ -65,7 +65,10 @@ func parseSupervisorStatus(out string) map[string]string {
// checkInboundRelayOff asserts the default image does not accept mail on
// port 25: smtp/inet is absent from master.cf (Debian's stock listener is
// removed when INBOUND_RELAY_ENABLE is not true).
// removed when INBOUND_RELAY_ENABLE is not true). For an absent service
// postconf -M prints nothing on stdout and "warning: unmatched request" on
// stderr (Postfix 3.7.11, src/postconf/postconf_master.c,
// pcf_show_master_entries()).
func checkInboundRelayOff(s *stack) error {
out, err := s.execIn("selfpost", "postconf", "-M", "smtp/inet")
combined := out
+9 -4
View File
@@ -52,7 +52,10 @@ func testSenderCheck(t *testing.T, sc *scenario) {
{"the listed address", list, "billing@" + senderDomain, true},
{"the listed address with an extension, in envelope and header", who{listLogin, listPassword, "billing+march@" + senderDomain}, "billing+march@" + senderDomain, true},
// What a mail library does with a long display name: the line is
// folded, and Postfix hands the milter both of its lines.
// folded, and Postfix hands the milter both of its lines (Postfix
// 3.7.11, src/milter/milter8.c, milter8_header(): the value is the
// whole header as cleanup's MIME state machine collected it,
// continuation line included).
{"a long name, folded onto a second line", wildcard, "\"The Shop, its orders and its returns desk\"\r\n <orders@" + senderDomain + ">", true},
{"another registered domain", wildcard, "director@" + otherDomain, false},
@@ -113,7 +116,8 @@ func testSenderCheck(t *testing.T, sc *scenario) {
// testPanelDownDefersMail: the sender check is the panel's and Postfix
// attaches it strictly, so with the panel stopped mail is deferred - a
// temporary error, nothing accepted unchecked - and the container stays up
// (crashexit fires on PROCESS_STATE_FATAL only; a supervisor stop is STOPPED).
// (crashexit fires on PROCESS_STATE_FATAL only; a supervisor stop is STOPPED -
// supervisor 4.2.5, supervisor/process.py, Subprocess.finish()).
// Once the panel is back it has counted both what that cost: the time it did
// not answer under a running Postfix, and the message that was deferred.
//
@@ -221,8 +225,9 @@ func senderCheckDeferredToday(sc *scenario) (int, error) {
// message is deferred, and the count on Health sees it. Past the DATA
// command Postfix's cleanup, not smtpd, talks to the milters, so the warning
// that names the socket is cleanup's (audit 2026-10-12, M13: the counter read
// smtpd's lines only). The panel is frozen with SIGSTOP once DATA is
// answered, and thawed after the client has its reply.
// smtpd's lines only; Postfix 3.7.11, src/smtpd/smtpd.c, milter_send() to
// cleanup, src/cleanup/cleanup_milter.c). The panel is frozen with SIGSTOP
// once DATA is answered, and thawed after the client has its reply.
func testPanelHungMidMessage(t *testing.T, sc *scenario) {
before, err := senderCheckDeferredToday(sc)
if err != nil {
+4 -1
View File
@@ -21,6 +21,9 @@ WORKDIR /mail
# smtp-sink refuses to run as root without an explicit -u to switch to after
# binding — "-u root" satisfies that check while staying root throughout,
# which is fine here: throwaway container, isolated e2e network, never
# exposed.
# exposed. (Postfix 3.7.11, src/smtpstone/smtp-sink.c: -d "Dump each mail
# transaction to a single-message file" named by the template plus "a
# pseudo-random hexadecimal number"; main(): "-u option is required if running
# as root".)
ENTRYPOINT ["/usr/sbin/smtp-sink"]
CMD ["-u", "root", "-d", "/mail/%Y%m%d%H%M%S.", "0.0.0.0:25", "100"]
+8 -3
View File
@@ -161,7 +161,8 @@ func attemptSend(a sendAttempt) sendResult {
// waited for the panel could still race a not-yet-listening smtpd. A bare TCP
// connect is not enough: Docker's port proxy accepts on the host side before
// anything listens in the container and then resets the connection, which is
// how the level-1 rate-limit case once took a reset for its limit.
// how the level-1 rate-limit case once took a reset for its limit (seen on the
// stand, date not recorded; Docker's documentation does not describe it).
func waitForSMTPSReady(timeout time.Duration) error {
return waitFor("smtps port to greet", timeout, 300*time.Millisecond, func() (bool, error) {
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 2 * time.Second}, "tcp", smtpsAddr, &tls.Config{
@@ -232,8 +233,12 @@ func mailTwiceOnOneConnection(login, password, from string) [2]error {
// the panel has just created is written into the sender map and Postfix is
// reloaded, and an smtpd that was idle from before the reload can still take
// a connection in the second that follows and answer 553 "not owned by user"
// (seen on the stand; two seconds after the save it no longer happened, at 0,
// 10, 40 or 110 s of waiting). It goes as far as RCPT and then RSET, so no
// (seen on the stand, date not recorded; two seconds after the save it no
// longer happened, at 0, 10, 40 or 110 s of waiting). Postfix 3.7.11: an smtpd
// reads the texthash map once, when it starts (html/DATABASE_README.html,
// "texthash"), and one from before the reload exits only when it next sees
// master's status pipe closed (src/master/multi_server.c,
// multi_server_abort()). It goes as far as RCPT and then RSET, so no
// message is sent and no row is written. Only that 553 is waited out, for 15
// s at most; any other answer fails at once.
func waitForSenderMap(login, password, from string) error {