From 2c7f0da3d8f4bbc181d52bcaf08636ebc9cc68ae Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Sun, 12 Jul 2026 22:37:16 +0300 Subject: [PATCH] Phase 5: full outbound Postfix relay (465/587 SASL+TLS, sender binding, milters) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generate the relay config from the environment at container start (build/postfix-config.sh, run from entrypoint.sh): - smtps 465 (implicit TLS) primary + optional submission 587 (STARTTLS), chroot=n so smtpd reaches the sasldb2/sender map under /data. - Cyrus SASL against the panel-maintained sasldb2; realm left implicit so the authenticated name equals the bare login in smtpd_sender_login_maps. - reject_sender_login_mismatch + relay/recipient restrictions with no permit_mynetworks: credentials-only, open relay impossible (spec 5, 5.1). - TLS cert/key from TLS_CERT_FILE/TLS_KEY_FILE; daily postfix reload picks up renewed certs (postfix-cert-reload.sh under supervisord, spec 5.2). - anvil level-1 rate limit from env (spec 5 p.5). - Milter chain with per-milter action: OpenDKIM strict (tempfail), journal fail-open (accept) so monitoring never blocks the relay (spec 7.3). Two integration fixes found on the server: - postconf -F '*/*/chroot=n': Debian's chrooted delivery agent can't read /etc/resolv.conf, so MX lookups failed and mail never left. - entrypoint sets /run/opendkim and /run/selfpost to group selfpost + setgid, and the journal stub chmods its socket 0660, so postfix can connect to both milter sockets (strict OpenDKIM was milter-rejecting all mail otherwise). Verified on selfpost.example.com: gofmt/vet/test green, image builds; container e2e — 465 auth+send DKIM-signed (d=domain,s=selfpost), 587 STARTTLS auth, cross-domain sender 553, list-mode per-address binding, unauth relay 554, real outbound delivery reaching the recipient MX over TLS. Co-Authored-By: Claude Opus 4.8 --- build/Dockerfile | 16 ++-- build/entrypoint.sh | 19 ++++ build/postfix-cert-reload.sh | 25 +++++ build/postfix-config.sh | 172 +++++++++++++++++++++++++++++++++++ build/supervisord.conf | 15 +++ cmd/panel/journalstub.go | 7 ++ 6 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 build/postfix-cert-reload.sh create mode 100644 build/postfix-config.sh diff --git a/build/Dockerfile b/build/Dockerfile index ab5ebd2..432ed78 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -51,10 +51,6 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \ ca-certificates \ && rm -rf /var/lib/apt/lists/* -# Postfix logs to a plain file (via its built-in postlogd) so the panel's -# log-tailer has something to follow and container logging works without syslog. -RUN postconf -e "maillog_file=/var/log/mail.log" - # Unprivileged user for the panel process (spec 7.6.8). RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel @@ -80,13 +76,19 @@ COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup COPY build/opendkim.conf /etc/opendkim.conf COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh +COPY build/postfix-config.sh /usr/local/bin/postfix-config.sh +COPY build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh COPY build/crashexit.py /usr/local/bin/crashexit.py COPY build/entrypoint.sh /usr/local/bin/entrypoint.sh COPY build/supervisord.conf /etc/supervisor/supervisord.conf -RUN chmod +x /usr/local/bin/postfix-wrapper.sh /usr/local/bin/crashexit.py /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/postfix-wrapper.sh /usr/local/bin/postfix-config.sh \ + /usr/local/bin/postfix-cert-reload.sh /usr/local/bin/crashexit.py \ + /usr/local/bin/entrypoint.sh -# 8080 panel; 25 outbound; 465/587 inbound submission (used from Phase 5). -EXPOSE 8080 25 465 587 +# Published submission ports: 465 (smtps, primary) and 587 (submission, optional) +# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client, +# which needs no inbound listener or EXPOSE. +EXPOSE 8080 465 587 # The entrypoint fixes /data ownership (bind mount) as root, then execs # supervisord, which becomes PID 1 and owns process supervision (spec 4). diff --git a/build/entrypoint.sh b/build/entrypoint.sh index cb4e356..a2854b8 100644 --- a/build/entrypoint.sh +++ b/build/entrypoint.sh @@ -51,4 +51,23 @@ chown -R panel:selfpost /data/postfix chmod 2750 /data/postfix chmod 0640 /data/postfix/sender_login_maps +# Milter socket directories (spec 5 p.3, 7.3). From Phase 5 Postfix (user +# `postfix`) must actually CONNECT to both milter sockets — OpenDKIM's and the +# panel's journal-milter — not just probe them at start-up. The sockets are +# created by the opendkim and panel users respectively, so bridge them to +# `postfix` through the shared `selfpost` group: group-owned + setgid dirs mean +# each socket created inside inherits group `selfpost`, and group-traversable +# (2750) lets postfix reach it. Without this, smtpd cannot talk to OpenDKIM and, +# because signing is strict (default_action=tempfail), rejects all mail. +mkdir -p /run/opendkim /run/selfpost +chown opendkim:selfpost /run/opendkim +chown panel:selfpost /run/selfpost +chmod 2750 /run/opendkim /run/selfpost + +# Generate the outbound-relay Postfix configuration from the environment (spec +# 5). Kept out of the image build so cert paths, rate limits, hostname and the +# optional 587 service are all driven by env at run time, and re-derived on every +# start the same way the /data normalisation above is. +/usr/local/bin/postfix-config.sh + exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf diff --git a/build/postfix-cert-reload.sh b/build/postfix-cert-reload.sh new file mode 100644 index 0000000..145a5c1 --- /dev/null +++ b/build/postfix-cert-reload.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Periodic `postfix reload` so refreshed TLS certificates are picked up (spec +# 5.2 p.4). The reverse-proxy renews the PEM files in the read-only mount every +# few months; Postfix only re-reads them on reload. A simple daily reload is +# more than enough (a day of staleness is harmless) and far simpler than an +# inotify watcher — the spec explicitly prefers this. +# +# Runs under supervisord as root, so it can reload Postfix directly. It sleeps +# first, then reloads in a loop: no reload at container start (the wrapper is +# still bringing Postfix up then) and none until at least one interval has +# passed. A reload is harmless when nothing changed. +set -eu + +INTERVAL="${TLS_RELOAD_INTERVAL_SECONDS:-86400}" + +while true; do + sleep "${INTERVAL}" + if postfix reload; then + echo "cert-reload: postfix reloaded (periodic TLS refresh)" + else + # Never exit non-zero: a transient reload failure must not trip the + # crashexit listener and take the container down. Log and retry next cycle. + echo "cert-reload: postfix reload failed, will retry after ${INTERVAL}s" >&2 + fi +done diff --git a/build/postfix-config.sh b/build/postfix-config.sh new file mode 100644 index 0000000..0b64d1c --- /dev/null +++ b/build/postfix-config.sh @@ -0,0 +1,172 @@ +#!/bin/sh +# Generate the outbound-relay Postfix configuration (spec 5, 5.1, 5.2). +# +# Run as root from entrypoint.sh on every container start, BEFORE supervisord +# starts the Postfix wrapper. Like the rest of SelfPost's runtime state it is +# re-derived from the environment on each start rather than persisted, so a +# fresh, restarted or restored container always ends up with the same config +# (the only panel-edited Postfix file, sender_login_maps, lives under /data and +# is generated separately by the panel — spec 5.1). +# +# main.cf is written with `postconf -e`, the master.cf submission services with +# `postconf -M`/`-P`. No user input is interpolated: every value here comes from +# a fixed literal or a container environment variable (spec 7.6.3). +set -eu + +# --- environment knobs (spec 8) ---------------------------------------------- +# Server hostname: used as HELO name AND, crucially, as the Cyrus SASL realm the +# 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. +HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}" + +# TLS material supplied by the reverse-proxy through a read-only bind mount +# (spec 5.2). The relay requires TLS on 465; if these files are absent the +# master still starts but TLS handshakes on 465 fail until they appear. +TLS_CERT="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}" +TLS_KEY="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}" + +# Level-1 rate limit (native Postfix anvil, spec 5 p.5 / 7.4). Conservative +# defaults, sensible during IP warm-up (spec 10). +RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}" +RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}" + +# Milter sockets: OpenDKIM (signing, strict) and the panel journal-milter +# (monitoring, fail-open). Fixed container paths, matched by postfix-wrapper.sh. +OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}" +JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}" + +# Persistent panel-managed sender map (spec 5.1); texthash needs no postmap, so +# the unprivileged panel can rewrite it and just ask for a reload. +SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}" +SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}" + +# Optional submission service on 587 (spec 5 p.1: off by default, enabled only +# when a client library needs STARTTLS on 587 instead of implicit TLS on 465). +SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}" + +# --- main.cf ----------------------------------------------------------------- +postconf -e \ + "myhostname=${HOSTNAME_VALUE}" \ + "maillog_file=/var/log/mail.log" \ + "mydestination=" \ + "relayhost=" \ + "inet_interfaces=all" \ + "inet_protocols=all" + +# This is an outbound relay: no local delivery, no per-user aliases. Empty +# these so a misfiled recipient never gets delivered locally. +postconf -e \ + "local_recipient_maps=" \ + "alias_maps=" \ + "alias_database=" + +# Outbound delivery: straight to the recipient MX, opportunistic TLS (spec 5 p.2). +postconf -e \ + "smtp_tls_security_level=may" \ + "smtp_tls_note_starttls_offer=yes" + +# TLS server material shared by every inbound service (spec 5.2). auth_only +# guarantees credentials are never accepted before TLS is up on any port. +postconf -e \ + "smtpd_tls_cert_file=${TLS_CERT}" \ + "smtpd_tls_key_file=${TLS_KEY}" \ + "smtpd_tls_security_level=may" \ + "smtpd_tls_auth_only=yes" \ + "smtpd_tls_loglevel=1" + +# SASL: Cyrus with the local sasldb2 the panel maintains (spec 5.1). The realm +# is left implicit (smtpd_sasl_local_domain empty) so the authenticated name +# Postfix 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. +postconf -e \ + "smtpd_sasl_auth_enable=yes" \ + "smtpd_sasl_type=cyrus" \ + "smtpd_sasl_path=smtpd" \ + "smtpd_sasl_local_domain=" \ + "smtpd_sasl_security_options=noanonymous" \ + "smtpd_sasl_tls_security_options=noanonymous" \ + "broken_sasl_auth_clients=yes" + +# Sender binding (spec 5.1 p.3, the critical anti-spoofing control). texthash +# resolves the full address first, then the "@domain" wildcard, so both address +# modes work from the same map. +postconf -e \ + "smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" + +# 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 +# (spec 5 p.1/p.4, 5.1). This is what makes an open relay impossible. +postconf -e \ + "smtpd_helo_required=yes" \ + "smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \ + "smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \ + "smtpd_sender_restrictions=reject_sender_login_mismatch, permit" + +# Level-1 rate limit by client IP (spec 5 p.5). Backstop that keeps working even +# if the journal-milter (level 2, Phase 8) is down. +postconf -e \ + "smtpd_client_message_rate_limit=${RATE_MSGS}" \ + "anvil_rate_time_unit=${RATE_WINDOW}s" + +# Milter chain (spec 5 p.3, 7.3). OpenDKIM signs and is treated strictly +# (default_action=tempfail: if it is unreachable, defer rather than send +# unsigned). The journal-milter is monitoring only and is fail-open +# (default_action=accept): its failure must never block the relay. Per-milter +# settings use Postfix 3.0+ brace syntax. +postconf -e \ + "milter_protocol=6" \ + "milter_default_action=tempfail" \ + "smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \ + "non_smtpd_milters=" + +# --- master.cf: inbound submission services ---------------------------------- +# smtps (465, implicit/wrapper TLS) — the primary, always-on submission service +# (spec 5 p.1). chroot=n so smtpd can read the sasldb2 and sender map under /data +# and the Cyrus config outside any chroot. +postconf -M "smtps/inet=smtps inet n - n - - smtpd" +postconf -P \ + "smtps/inet/smtpd_tls_wrappermode=yes" \ + "smtps/inet/smtpd_sasl_auth_enable=yes" \ + "smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject" + +# submission (587, STARTTLS) — optional (spec 5 p.1). 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. +if [ "${SUBMISSION_ENABLE}" = "true" ]; then + postconf -M "submission/inet=submission inet n - n - - smtpd" + postconf -P \ + "submission/inet/smtpd_tls_security_level=encrypt" \ + "submission/inet/smtpd_sasl_auth_enable=yes" \ + "submission/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject" +else + postconf -MX "submission/inet" 2>/dev/null || true +fi + +# Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery +# agent and others chrooted to /var/spool/postfix, where they cannot read +# /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail +# never leaves. 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. +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 (spec 5.1). PLAIN/LOGIN +# only — both are safe because TLS is mandatory before auth on every port. +mkdir -p /etc/postfix/sasl +cat > /etc/postfix/sasl/smtpd.conf <