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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-1
@@ -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
|
||||
|
||||
+19
-2
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
+18
-3
@@ -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/<domain>/ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
+128
-20
@@ -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 <<EOF
|
||||
pwcheck_method: auxprop
|
||||
@@ -500,7 +606,9 @@ mech_list: PLAIN LOGIN
|
||||
EOF
|
||||
|
||||
# Validate the generated configuration; fail loudly if postconf produced
|
||||
# anything Postfix rejects, before the wrapper tries to start it.
|
||||
# anything Postfix rejects, before the wrapper tries to start it. (Postfix
|
||||
# 3.7.11, conf/postfix-script, "check": "check-fatal || exit 1"; check-fatal
|
||||
# also runs "post-install create-missing", which builds the queue tree.)
|
||||
set +e
|
||||
check_out=$(postfix check 2>&1)
|
||||
ec=$?
|
||||
|
||||
@@ -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
|
||||
|
||||
+18
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user