diff --git a/build/Dockerfile b/build/Dockerfile index f975001..7c2d24b 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -36,6 +36,16 @@ ENV DEBIAN_FRONTEND=noninteractive # Preseed Postfix so its install is non-interactive and yields a working # main.cf. The real relay configuration is generated by the panel. +# (postfix 3.7.11-0+deb12u1, debian/templates: postfix/main_mailer_type and +# postfix/mailname are the questions the install asks.) +# What else arrives through Depends, and is used here: openssl, by +# ca-certificates ("Depends: openssl (>= 1.1.1)", packages.debian.org/bookworm/ +# ca-certificates) - postfix-cert-reload.sh; python3, by supervisor (supervisor +# 4.2.5-1, debian/control, ${python3:Depends}) - crashexit.py. sasl2-bin brings +# saslpasswd2 and libsasl2-modules the PLAIN and LOGIN plugins; the sasldb +# auxprop plugin is in libsasl2-modules-db, a Depends of libsasl2-2 +# (cyrus-sasl2 2.1.28+dfsg-10, debian/control and debian/*.install). db-util +# ships /usr/bin/db_dump (packages.debian.org/bookworm/amd64/db-util/filelist). RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \ && echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \ && apt-get update \ @@ -65,6 +75,8 @@ RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel # The id is fixed, and it is the one the group has always had: an operator # gives the TLS private key on the host to this group by number, so that the # panel can put it into a full backup (guide § Taking a full backup). +# groupadd(8) refuses a --gid already in use, so a package that took 998 would +# fail this build rather than share the group. RUN groupadd --system --gid 998 selfpost \ && usermod -aG selfpost panel \ && usermod -aG selfpost opendkim \ @@ -81,6 +93,9 @@ RUN groupadd --system --gid 998 selfpost \ # to write the maildrop directory (internal/notify/mailer.go). It does not let # the panel bypass any Postfix restriction that a local user does not already # have through the world-executable sendmail. +# (Postfix 3.7.11, conf/postfix-files: postdrop and postqueue are +# root:$setgid_group 2755. Linux, Documentation/userspace-api/no_new_privs.rst: +# with no_new_privs, execve() no longer changes the gid for a setgid file.) RUN usermod -aG postdrop panel # Runtime directories: milter sockets and the consolidated persistent root. diff --git a/build/crashexit.py b/build/crashexit.py index d7f2c5e..53cbfe7 100644 --- a/build/crashexit.py +++ b/build/crashexit.py @@ -8,6 +8,12 @@ lingering "alive" with a dead Postfix/OpenDKIM/panel. Communication with supervisord uses the event listener protocol over stdin and stdout, so stdout must carry only protocol tokens. + +supervisor 4.2.5, docs/events.rst, "Event Listener Notification Protocol": the +listener writes READY\\n, reads one header line of key:value tokens whose len +is the payload's length, reads the payload, and answers "RESULT 2\\nOK". +supervisord starts the listener as its own child, and on SIGTERM it shuts down +(docs/running.rst, "Signals"); it is PID 1 here (build/entrypoint.sh, exec). """ import os import signal diff --git a/build/entrypoint.sh b/build/entrypoint.sh index 15e4419..0aecdaa 100644 --- a/build/entrypoint.sh +++ b/build/entrypoint.sh @@ -62,6 +62,8 @@ esac # trees under /data as other users. Go's testing.TempDir is 0700, and a bare # chown would leave that mode in place — opendkim then cannot read KeyTable and # the container crash-loops (e2e TestHostnameGate/valid_hostname_starts). +# (Go: testing.TempDir uses os.MkdirTemp, src/os/tempfile.go: "created with +# mode 0o700".) chown panel:panel /data chmod 755 /data # Restored backups or previously-created state may contain panel-owned files @@ -76,7 +78,8 @@ find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -ex # tree on every start so it is correct whether /data is fresh, restarted, or # just restored from a backup: # - group `selfpost` + setgid on directories so anything the panel creates -# inherits the shared group OpenDKIM traverses; +# inherits the shared group OpenDKIM traverses (inode(7), "The +# set-group-ID bit": files created in such a directory take its group); # - private keys and tables group-readable (0640); # - both table files present (empty is fine) BEFORE OpenDKIM starts, so the # daemon comes up cleanly with no domains yet. @@ -135,6 +138,11 @@ chmod 0640 /data/postfix/sender_login_maps # directory keeps the shared group on anything created inside it later, and # 2750 keeps it group-traversable but not group-writable — logrotate refuses to # rotate a log whose directory is writable by a non-root group. +# Postfix 3.7.11: src/util/logwriter.c, logwriter_open_or_die(), creates the +# file 0644, under master's umask(077) (src/master/master.c) - 0600. logrotate +# 3.21.0, logrotate.c, findNeedRotating(): "skipping ... because parent +# directory has insecure permissions" when the directory's group is not root +# and has write permission. mkdir -p /data/log [ -e /data/log/mail.log ] || : > /data/log/mail.log chown -R postfix:selfpost /data/log @@ -160,6 +168,7 @@ chmod 2750 /run/opendkim /run/selfpost # running container, and a restart of the whole container is not the check's # downtime (guide § Health) - a mark left from before would count the whole # outage when supervisord cannot say since when Postfix runs. +# (test(1), -S: true for any socket file, whether or not anything listens.) rm -f /run/opendkim/*.sock /run/selfpost/*.sock /run/selfpost/*.alive # Generate the outbound-relay Postfix configuration from the environment (spec @@ -184,6 +193,10 @@ chgrp postdrop /data/postfix/queue/public /data/postfix/queue/maildrop # It would also leave the queue directory itself and pid/ owned by postfix. # Postfix keeps those two owned by root (master, which runs as root, is the only # writer) and logs "warning: not owned by root" for each at every start. +# (Postfix 3.7.11, conf/postfix-files: $queue_directory root 755, maildrop +# $setgid_group 730, public $setgid_group 710, pid root 755, the other queue +# directories $mail_owner 700; conf/postfix-script, "check-warn": "not owned by +# root".) chown root /data/postfix/queue /data/postfix/queue/pid exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf diff --git a/build/logrotate-loop.sh b/build/logrotate-loop.sh index ed59572..ef962bd 100644 --- a/build/logrotate-loop.sh +++ b/build/logrotate-loop.sh @@ -7,18 +7,35 @@ # before the reload is lost. `create` (rather than `nocreate`) matters here # beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by # postfix, which the unprivileged panel process cannot read — confirmed on a -# live container — so logrotate must be the one to create it. +# live container (date not recorded), and so in the source: Postfix 3.7.11, +# src/util/logwriter.c, logwriter_open_or_die(), creates it 0644 under master's +# umask(077), src/master/master.c — so logrotate must be the one to create it. +# The reload: Postfix 3.7.11, conf/postfix-script, "logrotate" (mv, then kill +# -HUP master); master_conf.c, master_config() -> master_restart_service() -> +# master_status_cleanup(), on which postlogd exits (src/master/dgram_server.c, +# dgram_server_abort() on MASTER_STATUS_FD), and the next postlogd opens the +# path anew (src/postlogd/postlogd.c, logwriter_open_or_die()). # # logrotate itself only rotates once the configured "daily" period has elapsed # (tracked in /var/lib/logrotate/status), so it is safe to invoke this more # often than daily — polling merely bounds how late a legitimate rotation runs. +# (logrotate 3.21.0-1: debian/rules, --with-state-file-path=/var/lib/logrotate/ +# status. That file is in the container's own filesystem: a recreated container +# starts without it, and logrotate.c, newState(), records a log it has no state +# for as rotated today, so the first rotation after a recreate comes a day +# later.) set -eu CONFIG=/etc/logrotate.d/mail INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}" # logrotate refuses configs writable by group or others and exits 0 while -# ignoring them — fail here so supervisord reports the fault. +# ignoring them — fail here so supervisord reports the fault. (logrotate +# 3.21.0, config.c, readConfigFile(): as root, mode & 0022 gives "Ignoring ... +# because it is writable by group or others." and returns 0, not an error; a +# config not owned by root is ignored the same way. "Potentially dangerous +# mode" is only a warning there, for any mode & 07533 other than 0400 - this +# loop chooses to treat it as fatal too.) logrotate_config_ok() { mode=$(stat -c '%a' "$CONFIG") mode=${mode#0} diff --git a/build/logrotate-mail.conf b/build/logrotate-mail.conf index 9912d40..3cdd5e5 100644 --- a/build/logrotate-mail.conf +++ b/build/logrotate-mail.conf @@ -4,6 +4,9 @@ # build/postfix-config.sh (maillog_file) and build/entrypoint.sh: postlogd # writes it as user postfix, the unprivileged panel reads it through the shared # selfpost group, hence create 0640 postfix selfpost rather than 0644 root root. +# (logrotate 3.21.0, logrotate.8: "create mode owner group" makes the new log +# right after the rotation; "delaycompress" leaves the previous log, .1, +# uncompressed until the next cycle.) /data/log/mail.log { daily rotate 14 diff --git a/build/opendkim.conf b/build/opendkim.conf index 82ac531..4a96e46 100644 --- a/build/opendkim.conf +++ b/build/opendkim.conf @@ -3,11 +3,16 @@ # Signing is strictly per-domain: each sending domain has its own key pair and # selector. The panel generates keys under /data/opendkim/keys// and # keeps KeyTable/SigningTable in sync as domains are added or removed, then asks -# supervisord to send this process SIGUSR1 to reload the tables. +# supervisord to send this process SIGUSR1 to reload the tables. (OpenDKIM +# 2.11.0~beta2, opendkim/opendkim.8.in: on SIGUSR1 the configuration file is +# re-read; opendkim/opendkim.c, dkimf_config_reload() -> dkimf_config_load(), +# opens SigningTable and KeyTable again with dkimf_db_open().) # # Both table files are created (empty) by entrypoint.sh before OpenDKIM starts, # so the daemon comes up cleanly on a fresh /data with no domains yet and simply -# signs nothing until the first domain is added. +# signs nothing until the first domain is added. (opendkim/opendkim-db.c, +# dkimf_db_open(): a file or refile table is read line by line; an empty file +# is an empty table, not an error.) Syslog no UMask 007 @@ -15,15 +20,25 @@ Mode s # SigningTable uses refile: so the "*@example.com" left-hand patterns match any # local-part for a domain; KeyTable maps each domain to its key and selector. +# (opendkim/opendkim.conf.5.in, SigningTable: with "refile" the keys are +# wildcard patterns matched against the From: address, in file order.) KeyTable /data/opendkim/KeyTable SigningTable refile:/data/opendkim/SigningTable # The private keys are owned by `panel` and read by `opendkim` through the shared # `selfpost` group, so they are deliberately group-readable (mode 0640). That is # safe on this single-tenant, private bind mount, but it trips OpenDKIM's default -# key-safety check, so the check is disabled here by design. +# key-safety check, so the check is disabled here by design. (opendkim/ +# opendkim.c, dkimf_checkfsnode(): a key file must be owned by root or by the +# executing uid, and a group-readable one must be in a group of no other user - +# `panel` owns these and `selfpost` has three members.) RequireSafeKeys no +# UMask 007 above leaves the socket group read-write (opendkim.conf.5.in, +# UMask: it applies to the socket); its group is selfpost by the setgid +# /run/opendkim (build/entrypoint.sh). UserID: the process takes the user's +# groups (opendkim.conf.5.in, UserID); with Background no and -f +# (build/supervisord.conf, opendkim.8.in) the supervised pid is OpenDKIM itself. Socket local:/run/opendkim/opendkim.sock PidFile /run/opendkim/opendkim.pid UserID opendkim diff --git a/build/postfix-cert-reload.sh b/build/postfix-cert-reload.sh index 6c91ab1..ac55225 100644 --- a/build/postfix-cert-reload.sh +++ b/build/postfix-cert-reload.sh @@ -11,6 +11,11 @@ # copies - not the mounted files, which a renewer may be rewriting - are a # certificate and the key that belongs to it, renames both over the targets # and reloads Postfix. When nothing changed it does nothing. +# (Postfix 3.7.11: smtpd loads the certificate in pre_jail_init(), once per +# process, src/smtpd/smtpd.c; "postfix reload" is SIGHUP to master, +# conf/postfix-script, after which the old smtpd processes exit on the closed +# status pipe, src/master/master_conf.c, master_config() -> +# master_restart_service(), and new ones load the files again.) # # Runs under supervisord as root. It sleeps first: at container start the # wrapper is still bringing Postfix up. It never exits non-zero, so a failure diff --git a/build/postfix-config.sh b/build/postfix-config.sh index c7b4ab2..5abb0da 100644 --- a/build/postfix-config.sh +++ b/build/postfix-config.sh @@ -23,6 +23,19 @@ set -eu # splits a restriction list on commas as well as on whitespace, so every value # passed here is written with commas only; a value with whitespace is a bug in # this script and stops the container. +# +# Postfix 3.7.11, man/man5/master.5, "-o name=value (short form)", NOTE 2: no +# whitespace in a short-form value (the long form "-o { name = value }" would +# keep it; this script does not write it). src/postconf/postconf_edit.c, +# pcf_edit_master(): "-Pe does not accept whitespace in parameter value". +# src/smtpd/smtpd_check.c, smtpd_check_parse(), and src/milter/milter.c, +# milter_new(), split their lists on CHARS_COMMA_SP: commas or whitespace. +# +# master.5, SYNTAX: a line that starts with whitespace continues a logical +# line, so the "-o" line awk inserts below the header belongs to the service. +# A later postconf -F/-P re-parses a matched entry with its continuation lines +# (postconf_edit.c, pcf_gobble_cf_line()) and prints it whole again, keeping +# these options. master_cf_set_smtp_inet_o() { param="$1" value="$2" @@ -62,11 +75,19 @@ master_cf_set_smtp_inet_o() { # sasldb2 accounts are looked up under. The panel creates accounts under realm # $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication # fails. Fall back to the container hostname only outside a real deployment. +# Postfix 3.7.11, src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create(): +# sasl_server_new() gets var_myhostname as serverFQDN; Cyrus SASL 2.1.28, +# plugins/sasldb.c, sasldb_auxprop_lookup(): with no user realm the account is +# looked up under serverFQDN. HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}" # TLS material supplied by the reverse-proxy through a read-only bind mount. The # relay requires TLS on 465; if these files are absent the master still starts -# but TLS handshakes on 465 fail until they appear. +# but TLS handshakes on 465 fail until they appear. Postfix 3.7.11, +# src/smtpd/smtpd.c: pre_jail_init() only warns when the TLS context cannot be +# made, and smtpd_proto() then drops each wrapper-mode connection ("Wrapper-mode +# request dropped ... TLS context initialization failed"); every new smtpd +# process loads the files again in pre_jail_init(). # # Postfix insists the private key is root-owned and mode 0600. The bind mount # is often :ro and owned by the host user (e2e TempDir / CI runner UID), so @@ -102,7 +123,9 @@ JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}" SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}" # Persistent panel-managed sender map; texthash needs no postmap, so the -# unprivileged panel can rewrite it and just ask for a reload. +# unprivileged panel can rewrite it and just ask for a reload. Postfix 3.7.11, +# html/DATABASE_README.html, "texthash": no postmap(1), and the file is read +# once by each process - a reload makes the new processes read it again. SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}" # Transit mail queue under /data so deferred/active messages survive container # recreate (architecture.md § Persistence). Distinct from sender_login_maps. @@ -151,6 +174,14 @@ MAIL_LOG_PATH="${MAIL_LOG:-/data/log/mail.log}" # maillog_file lives under the persistent /data bind mount (architecture.md § # Log tailer). Postfix's default maillog_file_prefixes are only /var and # /dev/stdout — without /data, `postfix check` rejects the path. +# inet_protocols=all in a bridge network without IPv6: Postfix 3.7.11, +# src/util/inet_proto.c, inet_proto_init(), keeps IPv6 when socket(PF_INET6) +# succeeds, and src/smtp/smtp_connect.c, smtp_connect_inet(), goes on to +# the next address when a connection fails - an AAAA that cannot be reached +# costs one failed attempt, then IPv4. Postfix +# 3.7.11, src/global/mail_params.h, DEF_MAILLOG_FILE_PFXS "/var, /dev/stdout"; +# src/global/maillog_client.c, maillog_client_init(): fatal "does not match any +# prefix", in every Postfix program that logs, postfix(1) itself included. postconf -e \ "myhostname=${HOSTNAME_VALUE}" \ "maillog_file=${MAIL_LOG_PATH}" \ @@ -160,7 +191,9 @@ postconf -e \ "inet_interfaces=all" \ "inet_protocols=all" -# postlogd is mandatory whenever maillog_file is set (MAILLOG_README). Debian's +# postlogd is mandatory whenever maillog_file is set (Postfix 3.7.11, +# html/MAILLOG_README.html, "Configuring logging to file"; conf/postfix-script, +# "check-fatal": "missing 'postlog' service in master.cf"). Debian's # stock master.cf usually has it; pin it explicitly so a stripped/upgraded # image cannot lose the service. postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd" @@ -178,7 +211,10 @@ postconf -e \ "smtp_tls_note_starttls_offer=yes" # TLS server material shared by every inbound service. auth_only guarantees -# credentials are never accepted before TLS is up on any port. +# credentials are never accepted before TLS is up on any port. Postfix 3.7.11, +# src/smtpd/smtpd_sasl_proto.c, smtpd_sasl_auth_cmd(): "504 5.5.4 Encryption +# required" while auth_only is set and TLS is not up; src/smtpd/smtpd.c, +# pre_jail_init(): wrappermode implies enforce, and enforce implies auth_only. postconf -e \ "smtpd_tls_cert_file=${TLS_CERT}" \ "smtpd_tls_key_file=${TLS_KEY}" \ @@ -191,6 +227,8 @@ postconf -e \ # uses for sender_login_maps is the BARE login the panel writes into the map; # the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as # the server realm, which equals the realm the accounts were created under. +# (Postfix 3.7.11, src/smtpd/smtpd_sasl_glue.c, smtpd_sasl_activate(): an empty +# smtpd_sasl_local_domain is a NULL user realm; see HOSTNAME_VALUE above.) postconf -e \ "smtpd_sasl_auth_enable=yes" \ "smtpd_sasl_type=cyrus" \ @@ -200,14 +238,19 @@ postconf -e \ "smtpd_sasl_tls_security_options=noanonymous" \ "broken_sasl_auth_clients=yes" -# Sender binding (the critical anti-spoofing control). texthash resolves the -# full address first, then the "@domain" wildcard, so both address modes work -# from the same map. +# Sender binding (the critical anti-spoofing control). smtpd looks the sender +# up in this map with mail_addr_find(), the order being smtpd's and not +# texthash's: the full address, the address without its extension, then the +# "@domain" wildcard - so both address modes work from the same map (Postfix +# 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch(); +# src/global/mail_addr_find.c, mail_addr_find_opt() with MA_FIND_DEFAULT). # # recipient_delimiter is said here and not left to the distribution's main.cf: # it is what lets an application listed as invoices@ send as invoices+march@, # and the panel's sender check makes the same second try for the From: line # (extensionDelimiter in internal/milter/sender.go) - the two must agree. +# (mail_addr_find_opt() strips the extension at var_rcpt_delim, +# strip_addr_internal(), for its second try.) postconf -e \ "smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \ "recipient_delimiter=+" \ @@ -216,7 +259,9 @@ postconf -e \ # Restrictions: authenticated clients only, no relay to foreign destinations, # and every authenticated sender address must be owned by its login. NO # permit_mynetworks anywhere — authorisation is by credentials, never by -# network. This is what makes an open relay impossible. +# network. This is what makes an open relay impossible. (Postfix 3.7.11, +# src/smtpd/smtpd_check.c, smtpd_check_rcpt(): the relay and recipient lists +# are evaluated in turn and the first REJECT ends them.) postconf -e \ "smtpd_helo_required=yes" \ "smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \ @@ -224,7 +269,11 @@ postconf -e \ "smtpd_sender_restrictions=reject_sender_login_mismatch, permit" # Level-1 rate limit by client IP. Backstop that keeps working even if the -# journal-milter (level 2) is down. +# journal-milter (level 2) is down. Postfix 3.7.11, src/smtpd/smtpd.c, +# mail_cmd(): "450 4.7.1 Error: too much mail from", except for clients in +# smtpd_client_event_limit_exceptions (default $mynetworks; Debian's debconf +# default, debian/templates "postfix/mynetworks", is loopback only). +# anvil_rate_time_unit is one global window for every anvil limit (postconf(5)). postconf -e \ "smtpd_client_message_rate_limit=${RATE_MSGS}" \ "anvil_rate_time_unit=${RATE_WINDOW}s" @@ -244,6 +293,13 @@ postconf -e \ # signs them with the key of the server's own name; the journal-milter is not # on this chain, since they are not an application's mail. Bounces Postfix # generates itself do not pass it (internal_mail_filter_classes is empty). +# +# Postfix 3.7.11: html/MILTER_README.html, "Different settings for different +# Milter applications" (the braces, Postfix 3.0 and later) and "Signing +# internally-generated bounce messages"; src/milter/milter.c, milter_*_event() +# and milter_message(): milters are asked in list order and the first non-empty +# reply ends the round; src/global/mail_params.h, DEF_INT_FILT_CLASSES +# INT_FILT_CLASS_NONE. postconf -e \ "milter_protocol=6" \ "milter_default_action=tempfail" \ @@ -254,7 +310,8 @@ postconf -e \ # address. The panel's milters judge every message by that address (an # application's client-address list) and record it (the inbound journal), and # the address given once at connect is not there for a connection's later -# messages - see MailFrom in internal/milter/milter.go. +# messages - see MailFrom in internal/milter/milter.go. The defaults: Postfix +# 3.7.11, src/global/mail_params.h, DEF_MILT_MAIL_MACROS. postconf -e \ "milter_mail_macros=i {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer} {client_addr}" @@ -264,7 +321,8 @@ postconf -e \ # journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail, # but within seconds rather than minutes. Values are well above any healthy # response time (signing/DB insert are sub-second), so they never fire in normal -# operation. +# operation. The defaults: Postfix 3.7.11, src/global/mail_params.h, +# DEF_MILT_CONN_TIME "30s", DEF_MILT_CMD_TIME "30s", DEF_MILT_MSG_TIME "300s". postconf -e \ "milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \ "milter_command_timeout=${MILTER_COMMAND_TIMEOUT:-15s}" \ @@ -274,6 +332,10 @@ postconf -e \ # smtps (465, implicit/wrapper TLS) — the primary, always-on submission # service. chroot=n so smtpd can read the sasldb2 and sender map under /data # and the Cyrus config outside any chroot. +# The names smtps, submission and smtp need no /etc/services: Postfix 3.7.11, +# src/global/mail_params.h, DEF_KNOWN_TCP_PORTS "lmtp=24, smtp=25, +# smtps=submissions=465, submission=587" (src/util/find_inet.c, +# find_inet_port() via filter_known_tcp_port()). postconf -M "smtps/inet=smtps inet n - n - - smtpd" postconf -P \ "smtps/inet/smtpd_tls_wrappermode=yes" \ @@ -283,7 +345,10 @@ postconf -P \ # submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only # difference is TLS is negotiated via STARTTLS, so require encryption before # auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart -# after disabling it does not leave the port listening. +# after disabling it does not leave the port listening. (Postfix 3.7.11, +# src/smtpd/smtpd.c, pre_jail_init(): smtpd_tls_security_level=encrypt sets +# enforce_tls, which implies smtpd_tls_auth_only; src/postconf/postconf_edit.c, +# pcf_edit_master(): -MX drops the matched entry, continuation lines included.) if [ "${SUBMISSION_ENABLE}" = "true" ]; then postconf -M "submission/inet=submission inet n - n - - smtpd" postconf -P \ @@ -295,7 +360,9 @@ else fi # --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) ----- -# Debian's stock master.cf enables smtp/inet. When both flags are off, remove +# Debian's stock master.cf enables smtp/inet (postfix 3.7.11-0+deb12u1, +# debian/patches/40_chroot_by_default.diff: "smtp inet n - y - - smtpd", +# uncommented). When both flags are off, remove # that listener so port 25 is not an inbound smtpd (outbound delivery uses # smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only # configured domains/recipients; DMARC accepts only allow-listed report addresses. @@ -320,8 +387,15 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true # postconf -P refuses them ("unmatched service_name/type") and leaves a # bare "pipe", which dies with "fatal: missing user= command-line # attribute" on every report. They go into the service line itself. + # (Seen in 1.7.0 through 1.9.5, date not recorded. Postfix 3.7.11: + # src/postconf/postconf_master.c, pcf_edit_master_param(), -P writes + # "-o name=value" and nothing else; postconf_edit.c warns "unmatched + # service_name/type" for a service master.cf does not have; + # src/pipe/pipe.c, get_service_attr(): "missing user= command-line + # attribute".) # ${recipient} is expanded by pipe(8), not by the shell: the command - # learns which report address the message came to from its argument. + # learns which report address the message came to from its argument + # (man/man8/pipe.8, ${recipient}: as many arguments as recipients). # One at a time (the 1 before "pipe"): reports are in no hurry, every run # writes to the database the panel writes to, and a hundred of them in # parallel - Postfix's default - is what a flood of reports would start. @@ -332,7 +406,10 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true # command reads from the container's environment is named there. # export_environment is a list split at whitespace in which Postfix # expands "$": a path with either would reach the command as another - # path, and it would quietly open a database of its own. + # path, and it would quietly open a database of its own. (Postfix 3.7.11, + # man/man8/pipe.8, export_environment; src/global/mail_params.h, + # DEF_EXPORT_ENVIRON "TZ MAIL_CONFIG LANG"; src/global/clean_env.c. The + # parameter is main.cf's, so every Postfix process exports these too.) case "${SELFPOST_DATA_DIR:-}${SELFPOST_DB_PATH:-}" in *[[:space:]]* | *'$'*) echo "postfix-config: SELFPOST_DATA_DIR and SELFPOST_DB_PATH must not contain whitespace or \$ when DMARC_REPORTS_ENABLE=true" >&2 @@ -351,7 +428,8 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}" fi # Comma, not a space, between check_recipient_access and its table: see - # master_cf_set_smtp_inet_o. + # master_cf_set_smtp_inet_o (master.5, NOTE 2: "or use commas instead of + # spaces"). RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}" else postconf -MX "dmarc-ingest/unix" 2>/dev/null || true @@ -369,10 +447,17 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true # relay domain too (the server's own name), so their table is a recipient # table as well; without it they are "User unknown in relay recipient # table" and no report is ever accepted while both features are on. + # (Postfix 3.7.11, src/smtpd/smtpd_check.c, smtpd_check_rcpt(): after + # the lists, check_recipient_rcpt_maps() runs unless the status is + # REJECT - an OK from check_recipient_access does not skip it.) RELAY_RECIPIENT_SETTING="texthash:${RELAY_RECIPIENTS_MAP}" if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}" fi + # The panel writes the same "[host]:port" into transport and + # tls_policy: the policy lookup key is the verbatim next-hop, port and + # brackets included (Postfix 3.7.11, README_FILES/TLS_README, "TLS + # policy table" note: ":smtp", ":25" and no port are different keys). postconf -e \ "relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \ "smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}" @@ -389,7 +474,10 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true # domain — polluting Deliveries and letting a forged From: consume that # domain's level-2 rate-limit budget. So smtpd_milters is always overridden # here: the optional antispam milter, then - with the inbound relay on - the - # panel's inbound journal milter, or nothing at all. + # panel's inbound journal milter, or nothing at all. (Postfix 3.7.11, + # man/man5/master.5: "-o" overrides the main.cf parameter for this service; + # src/smtpd/smtpd.c hands its own milter list to cleanup, milter_send(), + # and src/cleanup/cleanup_milter.c, cleanup_milter_receive(), uses it.) INBOUND_MILTERS="" if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then # A glob's "*" matches anything, so the shape alone would pass @@ -422,6 +510,11 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true # never refuses, and must never hold up someone else's mail: accept when # it is down. A comma, not a space, between the two - see # master_cf_set_smtp_inet_o. + # Postfix 3.7.11: src/milter/milter.c, milter_message(), sends the + # message to each milter in list order; src/milter/milter8.c, + # milter8_event(), applies a header a milter adds (SMFIR_ADDHEADER) to + # the queue file at once, and the next milter8_message() reads that + # file with rec_get(), which follows the added records. case "${INBOUND_JOURNAL_SOCK}" in *[!A-Za-z0-9._/-]*) echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2 @@ -453,7 +546,10 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true # syslog_name: this listener's lines read postfix/inbound/smtpd, the # submission ports' postfix/smtpd. The log tailer files a refusal on port 25 - # into the inbound journal and has to tell the two apart. + # into the inbound journal and has to tell the two apart. (Postfix 3.7.11, + # src/global/mail_task.c, mail_task(): the log tag is syslog_name "/" + # program name. Only smtpd carries it: cleanup and the rest are other + # services and log as postfix/.) postconf -M "smtp/inet=smtp inet n - n - - smtpd" postconf -P \ "smtp/inet/syslog_name=postfix/inbound" \ @@ -484,13 +580,23 @@ fi # Inside a container the chroot buys little (the container is the isolation # boundary) and breaks DNS/TLS trust-store access, so turn it off uniformly. Our # own smtps/submission services are already n; this covers the delivery agents -# and the rest. +# and the rest. (postfix 3.7.11-0+deb12u1: debian/patches/40_chroot_by_default.diff +# sets chroot y; debian/configure-instance.sh copies etc/resolv.conf and the +# like into the chroot, and nothing runs it in this container. chroot(2) also +# needs CAP_SYS_CHROOT, which deploy/docker-compose.yml drops with cap_drop: +# ALL - capabilities(7).) postconf -F "*/*/chroot=n" # --- Cyrus SASL app config for smtpd ----------------------------------------- # Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify # passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN # only — both are safe because TLS is mandatory before auth on every port. +# The file is found by a Debian patch, not by upstream Postfix: postfix +# 3.7.11-0+deb12u1, debian/patches/07_sasl_config.diff, xsasl_getconfpath() in +# src/xsasl/xsasl_cyrus_log.c gives Cyrus "$config_directory/sasl:/usr/lib/sasl", +# and smtpd_sasl_path=smtpd names smtpd.conf there. The keys: Cyrus SASL +# 2.1.28, doc/legacy/options.html (pwcheck_method, auxprop_plugin, +# sasldb_path, mech_list). mkdir -p /etc/postfix/sasl cat > /etc/postfix/sasl/smtpd.conf <&1) ec=$? diff --git a/build/postfix-wrapper.sh b/build/postfix-wrapper.sh index e8e7a4f..7caf178 100644 --- a/build/postfix-wrapper.sh +++ b/build/postfix-wrapper.sh @@ -39,4 +39,8 @@ for sock in $SOCKS; do done echo "postfix-wrapper: milter sockets ready, starting postfix" +# Postfix 3.7.11: postfix(1) execs conf/postfix-script (src/postfix/postfix.c), +# whose "start-fg" execs master only as PID 1 and otherwise runs "master -s" as +# its child; master then makes itself a session leader (src/master/master.c, +# setsid()). The process supervisord watches is that shell, not master. exec postfix start-fg diff --git a/build/supervisord.conf b/build/supervisord.conf index c66c5ce..88b68f3 100644 --- a/build/supervisord.conf +++ b/build/supervisord.conf @@ -3,7 +3,10 @@ ; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens ; the sender check's and the journal-milter's sockets), then the Postfix ; wrapper — which additionally blocks until the milter sockets are ready -; before starting Postfix. +; before starting Postfix. (supervisor 4.2.5, docs/configuration.rst, +; [program:x] "priority": "the start and shutdown ordering"; supervisor/ +; supervisord.py, runforever(), transitions every group in that order in one +; pass, with no wait for a program to be ready.) ; ; If any managed process exhausts its restart retries (FATAL), the crashexit ; event listener brings the whole container down so Docker's restart policy can @@ -22,7 +25,9 @@ loglevel=info ; OpenDKIM a reload signal after it rewrites the DKIM tables. It is still ; unreachable by world; only root and the two SelfPost services are in the ; group. No HTTP auth is configured because access is gated by socket -; permissions, not the network. +; permissions, not the network. (supervisor 4.2.5, docs/configuration.rst, +; [unix_http_server]: chmod and chown set the socket's mode and owner; without +; username and password no authentication is asked.) [unix_http_server] file=/run/supervisor.sock chown=root:selfpost @@ -45,6 +50,10 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 +; user=panel also gives the panel its supplementary groups, selfpost and +; postdrop (build/Dockerfile): supervisor 4.2.5, supervisor/options.py, +; drop_privileges(), os.setgroups() from the user's memberships in +; grp.getgrall(). [program:panel] command=/usr/local/bin/panel user=panel @@ -76,7 +85,10 @@ stderr_logfile_maxbytes=0 ; supervisord — over the group-accessible control socket — to run this program, ; which executes the canonical `postfix reload` as root. autostart is off (it ; only runs on demand) and a fast, clean exit is expected, so it never trips the -; crashexit listener. +; crashexit listener. (start-fg: see build/postfix-wrapper.sh. supervisor +; 4.2.5, supervisor/process.py, Subprocess.finish(): with startsecs=0 no exit +; counts as "too quickly", so even a failed run ends EXITED - never BACKOFF, +; never FATAL.) [program:postfix-reload] command=/usr/sbin/postfix reload autostart=false @@ -120,6 +132,9 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 +; supervisor 4.2.5, supervisor/process.py: a program enters FATAL only from +; BACKOFF, once startretries are spent; a stop asked of supervisord ends in +; STOPPED (Subprocess.finish(), self.killing), which this listener never sees. [eventlistener:crashexit] command=/usr/local/bin/crashexit.py events=PROCESS_STATE_FATAL diff --git a/deploy/antispam/docker-compose.antispam.yml b/deploy/antispam/docker-compose.antispam.yml index 588dfbb..753b0ea 100644 --- a/deploy/antispam/docker-compose.antispam.yml +++ b/deploy/antispam/docker-compose.antispam.yml @@ -24,6 +24,10 @@ services: - antispam antispam: + # The milter: rspamd 3.11.1, conf/worker-proxy.inc, "milter = yes" for the + # rspamd_proxy worker, which the image binds to *:11332 (rspamd-docker + # v3.11.1+3, patches/rspamd-docker-config.diff), and runs as uid 11333 + # (its Dockerfile, USER 11333:11333). image: rspamd/rspamd:3.11 restart: unless-stopped # No host ports: only SelfPost talks to the milter on the compose network. diff --git a/deploy/antispam/rspamd/milter_headers.conf b/deploy/antispam/rspamd/milter_headers.conf index 843bd2e..018a376 100644 --- a/deploy/antispam/rspamd/milter_headers.conf +++ b/deploy/antispam/rspamd/milter_headers.conf @@ -17,6 +17,18 @@ # SelfPost reads a verdict only next to a signature it can recompute. The # signature travels on with the mail and is of no use to whoever sees it: it # fits that one queue id. +# +# rspamd 3.11.1, src/plugins/lua/milter_headers.lua: a custom routine is called +# as function(task, common_meta) and returns error, headers to add, headers to +# remove, metadata; a remove value of 0 removes every header of that name +# (docs.rspamd.com/modules/milter_headers, "Custom routines"). +# src/lua/lua_cryptobox.c, rspamd_lua_hash_create(): "sha256" with a key is +# HMAC-SHA256 over EVP_sha256(), the key being the bytes given - here the hex +# text. task:get_queue_id() is the Queue-Id the proxy took from the milter +# macro "{i}" or "i" (src/libserver/milter.c, rspamd_milter_macro_http(); read +# back in src/libserver/protocol.c), which Postfix sends at +# end-of-message (Postfix 3.7.11, src/global/mail_params.h, DEF_MILT_EOD_MACROS +# "i"). use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"]; custom { diff --git a/deploy/antispam/rspamd/multimap.conf b/deploy/antispam/rspamd/multimap.conf index 4065462..225a3cb 100644 --- a/deploy/antispam/rspamd/multimap.conf +++ b/deploy/antispam/rspamd/multimap.conf @@ -3,6 +3,11 @@ # The allow and deny lists kept in the panel (Inbound > Filter lists). SelfPost # writes them as six files under data/antispam/, which the fragment mounts at # /etc/rspamd/selfpost; rspamd rereads a file when it changes, no restart. +# (rspamd 3.11.1, src/libserver/maps/map.c: a file map is watched with libev's +# ev_stat - inotify where the kernel has it, else a stat poll every +# map_watch_interval x map_file_watch_multiplier, 5min x 0.1, conf/options.inc; +# docs.rspamd.com/configuration/maps asks for an atomic rename, which +# internal/inbound writes with.) # # Deny wins over allow, whichever entries match. A denied sender is refused # before anything else is looked at (a prefilter). An allowed one is not @@ -10,7 +15,17 @@ # first decide - but given a weight no spam score outweighs, which a refusal # by the deny list still overrides. # -# A domain entry is that domain, not its subdomains. +# A domain entry is that domain, not its subdomains. (rspamd 3.11.1, +# src/libserver/maps/map_helpers.c: a hash map is looked up by the whole key, +# case-insensitively - rspamd_icase_hash, rspamd_lc_cmp; "email:domain" gives +# the address's domain part, src/plugins/lua/multimap.lua, apply_addr_filter().) +# +# A refusal is rspamd's "pre-result": multimap.lua calls task:set_pre_result() +# with the rule's action and message, and that action stands whatever the score +# (docs.rspamd.com/modules/multimap, "action"). The message reaches the client +# as "554 5.7.1 ": src/libserver/protocol.c, rspamd_scan_result_ucl(), +# puts it into messages.smtp_message, src/libserver/milter.c, +# rspamd_milter_send_task_results(), replies with it. # # SelfPost counts how often an entry decided a message by what these rules # leave behind: the deny message below and the names of the allow symbols. diff --git a/deploy/caddy/docker-compose.caddy.yml b/deploy/caddy/docker-compose.caddy.yml index 7cef432..10fd95c 100644 --- a/deploy/caddy/docker-compose.caddy.yml +++ b/deploy/caddy/docker-compose.caddy.yml @@ -11,10 +11,18 @@ # storage detail, not a documented public API — VERIFY the exact path against # the Caddy version you deploy (`docker compose exec caddy find /data/caddy/certificates -name '*.crt'`) # before wiring it into the volume mount below. +# Read in the source (caddyserver/certmagic, storage.go, KeyBuilder.SiteCert(): +# certificates///.crt; acmeissuer.go, issuerKey(): +# the CA URL's host and path, "acme-v02.api.letsencrypt.org-directory" for Let's +# Encrypt). caddy/modules/caddytls/automation.go, DefaultIssuers(): ZeroSSL is a +# second issuer only when an email is configured, and the Caddyfile below sets +# none. Read at the projects' master of 2026-10-14: `caddy:2` is a moving tag. # # Merge with the base file — run this from the deploy/ directory (Compose # resolves every relative path in both files against the directory of the -# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/...): +# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/... - +# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are +# evaluated relative to the base file"): # # docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index edad693..51f6be1 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -116,6 +116,13 @@ services: # FOWNER/FSETID are required by build/entrypoint.sh's permission # self-healing; without them chmod fails with EPERM and the container # crash-loops on start. + # Each capability above as capabilities(7) defines it (man7.org, + # "Capabilities list"); SETUID/SETGID are also what pipe(8) uses to become + # user=panel for dmarc-ingest. Not listed, so dropped: SYS_CHROOT, which + # chroot(2) needs - build/postfix-config.sh turns Postfix's chroot off. + # no-new-privileges only changes execve() (Linux, + # Documentation/userspace-api/no_new_privs.rst): root may still call + # setuid()/setgid() with these capabilities. security_opt: - no-new-privileges:true cap_drop: diff --git a/deploy/nginx/docker-compose.nginx.yml b/deploy/nginx/docker-compose.nginx.yml index 1bc15a3..9ed0984 100644 --- a/deploy/nginx/docker-compose.nginx.yml +++ b/deploy/nginx/docker-compose.nginx.yml @@ -8,7 +8,9 @@ # takes over 80/443 and proxies to the panel over the compose network # instead). Merge it with the base file — run this from the deploy/ directory # (Compose resolves every relative path in both files against the directory of -# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/...): +# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/... - +# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are +# evaluated relative to the base file"): # # docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d # @@ -54,4 +56,7 @@ services: - ./nginx/certbot-www:/var/www/certbot # Renewal twice a day is certbot's own recommended cadence; it no-ops # until a certificate is within its renewal window. + # (eff-certbot.readthedocs.io, User Guide, "Automated Renewals": the + # sample cron runs "certbot renew" at 0 and 12 h; renew acts only on a + # certificate near expiry.) entrypoint: sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done' diff --git a/deploy/nginx/nginx.conf.example b/deploy/nginx/nginx.conf.example index 4ca40a7..6fc3940 100644 --- a/deploy/nginx/nginx.conf.example +++ b/deploy/nginx/nginx.conf.example @@ -17,6 +17,8 @@ server { server { listen 443 ssl; + # nginx.org/en/docs/http/ngx_http_v2_module.html, "http2": "This directive + # appeared in version 1.25.1" (the fragment runs nginx:1.27). http2 on; server_name mail.example.com; diff --git a/deploy/traefik/docker-compose.traefik.yml b/deploy/traefik/docker-compose.traefik.yml index b2b6455..2079833 100644 --- a/deploy/traefik/docker-compose.traefik.yml +++ b/deploy/traefik/docker-compose.traefik.yml @@ -6,7 +6,9 @@ # # Merge with the base file — run this from the deploy/ directory (Compose # resolves every relative path in both files against the directory of the -# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/...): +# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/... - +# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are +# evaluated relative to the base file"): # # docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d @@ -25,7 +27,10 @@ services: # Traefik only proxies the panel; it never sees the mail ports. # These live on the selfpost service (not traefik's) because the # docker provider reads routing labels off the container being routed - # to, not off traefik itself. + # to, not off traefik itself. (doc.traefik.io/traefik/v3.1/providers/ + # docker/, "Routing Configuration": "Traefik uses container labels"; + # with exposedByDefault=false only containers labelled + # traefik.enable=true.) - traefik.enable=true - traefik.http.routers.selfpost.rule=Host(`mail.example.com`) - traefik.http.routers.selfpost.entrypoints=websecure diff --git a/deploy/traefik/extract-cert.sh b/deploy/traefik/extract-cert.sh index e942512..bbc6316 100644 --- a/deploy/traefik/extract-cert.sh +++ b/deploy/traefik/extract-cert.sh @@ -6,6 +6,13 @@ # by SelfPost/Postfix. # # Requires jq. Usage: ./extract-cert.sh +# +# The layout is not documented (doc.traefik.io/traefik/v3.1/https/acme/ says +# only "JSON file"); read in Traefik v3.1.0: pkg/provider/acme/local_store.go +# keeps map[resolver name]*StoredData, store.go StoredData{Account, +# Certificates}, provider.go Certificate{domain, certificate, key} with the PEM +# as []byte - base64 in JSON (Go encoding/json). "le" is the resolver +# docker-compose.traefik.yml names. set -eu ACME_JSON="${1:?path to acme.json}" diff --git a/internal/backup/backup.go b/internal/backup/backup.go index e7f091d..72769d8 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -88,7 +88,9 @@ type Params struct { // copy taken then can hold a message twice or not at all, so what it would // bring back is not the queue. Nor could the panel read it - Postfix keeps // every queue file readable by its own user alone, which is why the panel's -// backup failed outright from 1.3.0 to 2.0.0. What is not carried over is mail +// backup failed outright from 1.3.0 to 2.0.0 (date not recorded; Postfix +// 3.7.11, conf/postfix-files: active, deferred, incoming and the other queue +// directories are $mail_owner 700). What is not carried over is mail // still waiting for delivery; a start without a queue directory builds an // empty one. Whoever needs the queue stops the instance and archives the // project directory (guide § Taking a full backup). @@ -380,7 +382,11 @@ func writeTarFile(tw *tar.Writer, name string, mode fs.FileMode, srcPath string) // snapshotDB produces a consistent copy of the SQLite database at dbPath using // VACUUM INTO, so the backup captures a coherent point-in-time image even while // the panel is writing to the live file under WAL. It returns the snapshot path -// and a cleanup function the caller must defer. +// and a cleanup function the caller must defer. (sqlite.org/lang_vacuum.html, +// "VACUUM INTO": "transactional in the sense that the generated output +// database is a consistent snapshot"; modernc.org/sqlite v1.60.1, sqlite.go, +// applyQueryParams(): each _pragma is run as "pragma " on connect, +// busy_timeout first.) func snapshotDB(dbPath string) (path string, cleanup func(), err error) { dir, err := os.MkdirTemp("", "selfpost-backup-") if err != nil { diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index 4c580a3..bd0bd81 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -49,7 +49,8 @@ func seedDataDir(t *testing.T) (dataDir, dbPath, deployRoot string) { writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal") writeFile(t, filepath.Join(dataDir, "selfpost.db-shm"), "shm") // Postfix's delivery log and its rotated files: diagnostic output, not - // state, and the bulkiest thing under /data. + // state, and the bulkiest thing under /data. The line is composed by hand; + // only its absence from the archive matters. writeFile(t, filepath.Join(dataDir, "log", "mail.log"), "Aug 8 07:26:41 mail postfix/smtp[1]: ABC: to=, status=sent (ok)") writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older") writeFile(t, filepath.Join(deployRoot, ComposeFileName), "services:\n selfpost:\n image: test\n") diff --git a/internal/dmarc/ingest.go b/internal/dmarc/ingest.go index e855834..05d52c4 100644 --- a/internal/dmarc/ingest.go +++ b/internal/dmarc/ingest.go @@ -154,6 +154,10 @@ func extractAggregatePayload(raw []byte) ([]byte, error) { return readEncodedBody(textproto.MIMEHeader(msg.Header), msg.Body) } +// looksLikeAggregate takes a part by its file name or Content-Type. RFC 7489 +// § 7.2.1.1: the report is "application/gzip" if compressed and "text/xml" +// otherwise, its name ending ".xml" or ".xml.gz"; zip is outside the RFC and +// is what some reporters send (see decompress). func looksLikeAggregate(name string) bool { name = strings.ToLower(name) return strings.Contains(name, "xml") || strings.Contains(name, "gzip") || strings.Contains(name, "zip") diff --git a/internal/dmarc/parse.go b/internal/dmarc/parse.go index 5db3e85..e80434f 100644 --- a/internal/dmarc/parse.go +++ b/internal/dmarc/parse.go @@ -40,6 +40,11 @@ type ParsedRecord struct { HeaderFrom string } +// feedbackXML follows RFC 7489, Appendix C. The schema's targetNamespace is +// "http://dmarc.org/dmarc-xml/0.1"; a tag without a namespace matches the +// local name in any namespace (Go, src/encoding/xml/read.go: the namespace is +// compared only when the tag names one), so a report that declares one is +// read the same. type feedbackXML struct { XMLName xml.Name `xml:"feedback"` Metadata struct { @@ -160,6 +165,9 @@ func ParseAggregate(raw []byte) (ParsedReport, error) { return out, nil } +// dmarcRecordPasses: RFC 7489, Appendix C, PolicyEvaluatedType - dkim and spf +// are "The DMARC-aligned authentication result"; § 4.2: DMARC passes when one +// supported mechanism passes aligned. func dmarcRecordPasses(r ParsedRecord) bool { return strings.EqualFold(r.SPFResult, "pass") || strings.EqualFold(r.DKIMResult, "pass") } @@ -170,7 +178,8 @@ const maxAggregateXML = 8 << 20 // decompress unwraps the container the reporter used. Both are seen in the // wild: most senders gzip the XML, some (notably several Microsoft and Yahoo -// endpoints) ship it as a single-entry zip. +// endpoints) ship it as a single-entry zip. (A sighting: where and when not +// recorded. RFC 7489 § 7.2.1.1 names gzip only.) func decompress(raw []byte) ([]byte, error) { switch { case len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b: diff --git a/internal/dmarc/parse_test.go b/internal/dmarc/parse_test.go index a49aaf7..ab61a57 100644 --- a/internal/dmarc/parse_test.go +++ b/internal/dmarc/parse_test.go @@ -11,6 +11,9 @@ import ( "github.com/mixeme/selfpost/internal/store" ) +// sampleXML and the messages in this file are composed by hand, in the shape +// RFC 7489 gives (Appendix C, the schema; § 7.2.1.1, application/gzip and the +// .xml.gz name). The org name is any reporter's; no report was copied. const sampleXML = ` diff --git a/internal/inbound/lists.go b/internal/inbound/lists.go index f508657..5598904 100644 --- a/internal/inbound/lists.go +++ b/internal/inbound/lists.go @@ -33,6 +33,12 @@ type ListStore interface { // line, and the filter (rspamd, deploy/antispam/rspamd/multimap.conf) rereads // a file when it changes. Which list wins when both match is the filter's // configuration: deny. +// +// rspamd 3.11.1, src/libserver/maps/map.c: a file map is watched with libev's +// ev_stat (inotify, or a stat poll every map_watch_interval x +// map_file_watch_multiplier = 30 s, conf/options.inc), so a change takes +// effect within that, not at once; docs.rspamd.com/configuration/maps asks +// for the atomic rename atomicfile.Write does. type Lists struct { store ListStore dir string @@ -157,7 +163,10 @@ func (l *Lists) sync() error { } // Values were validated on the way in; this is the writer's own guard // against a row that got into the table some other way. In a map file a - // newline ends the entry, whitespace starts its value and # a comment. + // newline ends the entry, whitespace starts its value and # a comment + // (rspamd 3.11.1, src/libserver/maps/map_helpers.c, + // rspamd_parse_kv_list()). An IP map takes bare v4 and v6 addresses and + // CIDR networks (docs.rspamd.com/configuration/maps, IP maps). if err := configsafe.Token("filter list entry", e.Value, " \t\r\n#"); err != nil { return err } @@ -165,6 +174,8 @@ func (l *Lists) sync() error { } // The filter runs under another user in another container: the directory // and the files are readable by anyone. They hold addresses, not secrets. + // (The rspamd image runs as uid 11333: rspamd-docker v3.11.1+3, + // Dockerfile, USER 11333:11333.) if err := os.MkdirAll(l.dir, 0o755); err != nil { return fmt.Errorf("create %s: %w", l.dir, err) } @@ -297,6 +308,11 @@ func normalizeNote(raw string) (string, error) { // refuses with a message, which the log tailer files as the reply of a // rejected row. An allow rule adds a symbol named after the kind of entry, // which the journal milter keeps in the verdict of an accepted row. +// +// rspamd 3.11.1: the deny message reaches Postfix as the milter reply "554 +// 5.7.1 " (src/libserver/milter.c, rspamd_milter_send_task_results()); +// src/plugins/lua/milter_headers.lua, routine x-spamd-result, writes each +// symbol as "%s(%.2f)[%s]" - name, score, options. const ( denyReplyMark = "this server's deny list" allowMark = "SELFPOST_ALLOW_" diff --git a/internal/inbound/lists_test.go b/internal/inbound/lists_test.go index 4647d2d..f1d9136 100644 --- a/internal/inbound/lists_test.go +++ b/internal/inbound/lists_test.go @@ -314,6 +314,9 @@ func TestListsUsage(t *testing.T) { t.Fatal(err) } } + // The rows below are composed by hand. Their replies and verdicts follow + // rspamd 3.11.1's forms: "Spam message rejected" is the proxy's default + // reject_message (conf/worker-proxy.inc). // hour 1: the address and its domain are both on the deny list, and both fit. row("Spammer@Evil.Example", "192.0.2.9", store.SpamDecisionReject, store.SpamEngineRspamd, denied) // hour 2: another address of the domain. diff --git a/test/e2e/compose.override.yml b/test/e2e/compose.override.yml index 9f16fbf..6920a1b 100644 --- a/test/e2e/compose.override.yml +++ b/test/e2e/compose.override.yml @@ -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: diff --git a/test/e2e/delivery_log_test.go b/test/e2e/delivery_log_test.go index d259c11..d91fee2 100644 --- a/test/e2e/delivery_log_test.go +++ b/test/e2e/delivery_log_test.go @@ -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 diff --git a/test/e2e/dns_zone.go b/test/e2e/dns_zone.go index 82a8420..66cd800 100644 --- a/test/e2e/dns_zone.go +++ b/test/e2e/dns_zone.go @@ -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 -// limit), which resolvers concatenate back into one value. +// 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 { diff --git a/test/e2e/hostname_gate_test.go b/test/e2e/hostname_gate_test.go index b060949..e498db2 100644 --- a/test/e2e/hostname_gate_test.go +++ b/test/e2e/hostname_gate_test.go @@ -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) } diff --git a/test/e2e/mail_helpers.go b/test/e2e/mail_helpers.go index d125bcf..2898c6c 100644 --- a/test/e2e/mail_helpers.go +++ b/test/e2e/mail_helpers.go @@ -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 diff --git a/test/e2e/negative_test.go b/test/e2e/negative_test.go index 0348ba7..cb3cb74 100644 --- a/test/e2e/negative_test.go +++ b/test/e2e/negative_test.go @@ -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 { diff --git a/test/e2e/port25_client.go b/test/e2e/port25_client.go index 6727ce9..edeb46b 100644 --- a/test/e2e/port25_client.go +++ b/test/e2e/port25_client.go @@ -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) diff --git a/test/e2e/port25_config_check.go b/test/e2e/port25_config_check.go index 5947f01..822abb6 100644 --- a/test/e2e/port25_config_check.go +++ b/test/e2e/port25_config_check.go @@ -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) diff --git a/test/e2e/port25_features_test.go b/test/e2e/port25_features_test.go index a71d793..7ee76c3 100644 --- a/test/e2e/port25_features_test.go +++ b/test/e2e/port25_features_test.go @@ -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 ` @@ -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 diff --git a/test/e2e/process_check.go b/test/e2e/process_check.go index 8222f33..f2ccba5 100644 --- a/test/e2e/process_check.go +++ b/test/e2e/process_check.go @@ -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 diff --git a/test/e2e/sender_check_test.go b/test/e2e/sender_check_test.go index abc8e65..f08f128 100644 --- a/test/e2e/sender_check_test.go +++ b/test/e2e/sender_check_test.go @@ -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 ", 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 { diff --git a/test/e2e/sink/Dockerfile b/test/e2e/sink/Dockerfile index 016f405..b491aab 100644 --- a/test/e2e/sink/Dockerfile +++ b/test/e2e/sink/Dockerfile @@ -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"] diff --git a/test/e2e/smtp_client.go b/test/e2e/smtp_client.go index ed7a7bc..46dff01 100644 --- a/test/e2e/smtp_client.go +++ b/test/e2e/smtp_client.go @@ -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 {