release: 0.1.0: Phases 0-11: panel, Postfix/DKIM, milter, backup, Docker image

Initial feature-complete implementation of the v1.0 specification (phases 0-11 of `docs/implementation-plan.md`).
Added:
  - Panel (Go, single static binary) with SQLite persistence, one-time
  - Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and
  - Application (sender identity) management: SASL credentials via `sasldb2`,
  - Full Postfix relay config generated from env at container start: SMTPS 465,
  - Journal milter (pure Go, `go-milter`) recording every send to `send_log`;
  … (6 more in CHANGELOG)

Full detail: CHANGELOG.md (0.1.0).
This commit is contained in:
mix
2026-09-22 15:24:51 +03:00
commit 8ce050ef7f
157 changed files with 17631 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
# syntax=docker/dockerfile:1
#
# SelfPost — single Debian-slim image running postfix + opendkim + panel under
# supervisord (spec 4). Build from the repository root:
#
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
# ---- build stage -------------------------------------------------------------
FROM golang:1.26-bookworm AS build
WORKDIR /src
# Version stamped into both binaries; MUST match the image tag (spec 7.5.A).
ARG VERSION=dev
# Module metadata first for layer caching. go.sum arrived in Phase 2 with the
# SQLite driver and bcrypt.
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
ENV CGO_ENABLED=0
RUN go vet ./... \
&& go build -trimpath \
-ldflags "-X codeberg.org/mix/selfpost/internal/buildinfo.Version=${VERSION}" \
-o /out/panel ./cmd/panel \
&& go build -trimpath \
-ldflags "-X codeberg.org/mix/selfpost/internal/buildinfo.Version=${VERSION}" \
-o /out/selfpost-backup ./cmd/selfpost-backup
# ---- runtime stage -----------------------------------------------------------
FROM debian:bookworm-slim AS runtime
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 in Phase 5.
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 \
&& apt-get install -y --no-install-recommends \
postfix \
opendkim \
opendkim-tools \
sasl2-bin \
libsasl2-modules \
db-util \
supervisor \
logrotate \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Unprivileged user for the panel process (spec 7.6.8).
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
# Shared group bridging the unprivileged services (spec 5.1, 6): the panel
# generates per-domain DKIM keys, application SASL accounts (sasldb2) and the
# Postfix sender map, while OpenDKIM and Postfix (different users) must read
# them. Membership in this group — plus setgid dirs under /data (set up in
# entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read
# the sasldb2/sender map, and lets the panel reach the supervisor control socket
# to signal OpenDKIM/Postfix reloads without any process running as root.
RUN groupadd --system selfpost \
&& usermod -aG selfpost panel \
&& usermod -aG selfpost opendkim \
&& usermod -aG selfpost postfix
# The panel reads the mail queue with `postqueue -p` (spec 7.2.11, and the
# status page's queue card). postqueue is setgid postdrop, which normally gives
# it the group needed to reach Postfix's showq socket — but the documented
# deployment runs with `no-new-privileges`, which disables setgid transitions,
# so the panel would always see "Permission denied". Making `panel` a real
# member of postdrop grants the same access without relying on a setgid
# escalation the hardening deliberately forbids. postdrop membership is
# read-side only: it does not let the panel bypass any Postfix restriction that
# a local user does not already have through the world-executable sendmail.
RUN usermod -aG postdrop panel
# Runtime directories: milter sockets and the consolidated persistent root.
RUN mkdir -p /run/opendkim /run/selfpost /data \
&& chown opendkim:opendkim /run/opendkim \
&& chown panel:panel /run/selfpost /data
COPY --from=build /out/panel /usr/local/bin/panel
COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup
COPY build/opendkim.conf /etc/opendkim.conf
COPY build/logrotate-mail.conf /etc/logrotate.d/mail
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/logrotate-loop.sh /usr/local/bin/logrotate-loop.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/postfix-config.sh \
/usr/local/bin/postfix-cert-reload.sh /usr/local/bin/logrotate-loop.sh \
/usr/local/bin/crashexit.py /usr/local/bin/entrypoint.sh
# 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).
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Supervisor event listener: bring the container down on unrecoverable failure.
If any managed program exhausts its restart retries and enters FATAL, this
listener signals supervisord (its parent) to terminate, so the whole container
exits and Docker's `restart` policy can recreate it cleanly — rather than
lingering "alive" with a dead Postfix/OpenDKIM/panel (spec 4).
Communication with supervisord uses the event listener protocol over stdin and
stdout, so stdout must carry only protocol tokens.
"""
import os
import signal
import sys
def write_stdout(s):
sys.stdout.write(s)
sys.stdout.flush()
def main():
while True:
# Tell supervisord we are ready for the next event.
write_stdout("READY\n")
line = sys.stdin.readline()
if not line:
return
headers = dict(pair.split(":", 1) for pair in line.split())
payload_len = int(headers.get("len", 0))
if payload_len:
sys.stdin.read(payload_len)
# We only subscribe to PROCESS_STATE_FATAL, so any event means a managed
# program can no longer be restarted. Take the container down.
sys.stderr.write(
"crashexit: a managed process entered FATAL; shutting down container\n"
)
sys.stderr.flush()
os.kill(os.getppid(), signal.SIGTERM)
write_stdout("RESULT 2\nOK")
if __name__ == "__main__":
main()
+118
View File
@@ -0,0 +1,118 @@
#!/bin/sh
# Container entrypoint (runs as root, PID 1 until it execs supervisord).
#
# The persistent root /data is a host bind mount (spec 9), so it arrives owned
# by the host user (typically root), not by the unprivileged panel user that
# actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8).
# Fix its ownership here — the one place still running as root — before handing
# off to supervisord, which starts the panel as the panel user.
set -e
chown panel:panel /data
# Restored backups or previously-created state may contain panel-owned files
# under /data; make sure they stay writable without disturbing anything that a
# later phase deliberately hands to another service.
find /data -mindepth 1 -maxdepth 1 ! -user panel -exec chown -R panel:panel {} +
# DKIM key tree (spec 6, 9). The panel (user `panel`) generates keys and writes
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
# 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;
# - 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.
mkdir -p /data/opendkim/keys
for t in /data/opendkim/KeyTable /data/opendkim/SigningTable; do
[ -e "$t" ] || : > "$t"
done
chown -R panel:selfpost /data/opendkim
find /data/opendkim -type d -exec chmod 2750 {} +
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} +
# Application SASL accounts (spec 5.1, 9). The panel (user `panel`) writes the
# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP
# clients. Share it through the `selfpost` group the same way as the DKIM tree:
# setgid directory so new files inherit the group, and the database itself
# group-readable (0640). Postfix wiring to actually consult it lands in Phase 5.
mkdir -p /data/sasl
chown -R panel:selfpost /data/sasl
chmod 2750 /data/sasl
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
# Postfix sender_login_maps (spec 5.1). The panel writes it; Postfix reads it.
# Ensure the file exists (empty is fine) before Postfix starts so a reload that
# references it never fails on a missing file, and keep it group-readable.
mkdir -p /data/postfix
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
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
# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must
# simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the
# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and
# postfix-config.sh each fall back independently when it's unset — to
# `localhost` and to the container hostname respectively — so accounts get
# written under one realm and looked up under another and authentication
# silently fails for every application, while HELO also stops matching the
# PTR record and mail that does go out lands in spam. No fallback can be
# correct, so fail loudly here, before either side of that split has a chance
# to run, rather than leave a green panel with broken mail.
if [ -z "$SELFPOST_HOSTNAME" ]; then
cat >&2 <<'EOF'
FATAL: SELFPOST_HOSTNAME is not set.
This is the mail server's identity: it becomes the Postfix HELO/EHLO name,
the Cyrus SASL realm that application passwords are looked up under, and it
must match the TLS certificate's CN/SAN as well as this server's PTR (reverse
DNS) record. There is no safe default — guessing any one of these wrong
breaks authentication for every application or sends outgoing mail to spam,
silently.
Set it to the mail server's fully-qualified domain name, e.g.:
SELFPOST_HOSTNAME=mail.example.com
in the .env file next to your docker-compose.yml (see deploy/.env.example).
EOF
exit 1
fi
case "$SELFPOST_HOSTNAME" in
*[\ \ ]* | *://* | *:* )
echo "FATAL: SELFPOST_HOSTNAME must be a bare hostname (no scheme, port, or spaces): \"$SELFPOST_HOSTNAME\"" >&2
echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2
exit 1
;;
*.*)
;;
*)
echo "FATAL: SELFPOST_HOSTNAME must be a fully-qualified domain name (at least one dot): \"$SELFPOST_HOSTNAME\"" >&2
echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2
exit 1
;;
esac
# 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
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# Periodic logrotate for /var/log/mail.log (spec 9, 10). Rotation renames the
# file, recreates it (`create 0644 root root`, matching a cold container
# start), then runs `postfix reload` (the same mechanism `postfix logrotate`
# uses): postlogd keeps writing to the renamed inode until reload, and the
# panel's log-tailer holds its own descriptor on that inode, so nothing
# written before the reload is lost. `create` (rather than `nocreate`) matters
# here beyond timing: a reload-triggered recreate lands the file at 0600,
# which the unprivileged panel process cannot read — confirmed on a live
# container — so logrotate must be the one to create it at 0644.
#
# 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.
set -eu
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
while true; do
if logrotate /etc/logrotate.d/mail; then
:
else
echo "logrotate-loop: logrotate failed, will retry after ${INTERVAL}s" >&2
fi
sleep "${INTERVAL}"
done
+12
View File
@@ -0,0 +1,12 @@
/var/log/mail.log {
daily
rotate 14
missingok
notifempty
compress
delaycompress
create 0644 root root
postrotate
/usr/sbin/postfix reload
endscript
}
+30
View File
@@ -0,0 +1,30 @@
# OpenDKIM — per-domain signing (spec 6).
#
# 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 (spec 6.2, 6.5).
#
# 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.
Syslog no
UMask 007
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.
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.
RequireSafeKeys no
Socket local:/run/opendkim/opendkim.sock
PidFile /run/opendkim/opendkim.pid
UserID opendkim
Background no
+25
View File
@@ -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
+184
View File
@@ -0,0 +1,184 @@
#!/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="
# Bounded milter timeouts (spec 7.3): a *hung* milter (socket accepts but never
# replies) must fail open just like a crash, not stall mail acceptance until the
# Postfix defaults (300s content) elapse. With default_action per milter, a
# 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.
postconf -e \
"milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \
"milter_command_timeout=${MILTER_COMMAND_TIMEOUT:-15s}" \
"milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}"
# --- 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 <<EOF
pwcheck_method: auxprop
auxprop_plugin: sasldb
sasldb_path: ${SASLDB_PATH}
mech_list: PLAIN LOGIN
EOF
# Validate the generated configuration; fail loudly if postconf produced
# anything Postfix rejects, before the wrapper tries to start it.
postfix check
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
# Postfix start wrapper (spec 4): solves the cold-start race where Postfix would
# try to reach the milter sockets before they are listening.
#
# It blocks until BOTH milter sockets — OpenDKIM and the panel's journal-milter
# — are present, then execs `postfix start-fg`. If they are not ready within the
# timeout it exits non-zero WITHOUT starting Postfix, so supervisord/Docker see
# an explicit startup failure instead of a relay running blind.
#
# This handles cold start only. Runtime milter failures after a successful start
# are governed by fail-open (milter_default_action), configured in Phase 5.
set -eu
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
TIMEOUT="${MILTER_WAIT_TIMEOUT:-30}"
INTERVAL=1
elapsed=0
for sock in "$OPENDKIM_SOCK" "$JOURNAL_SOCK"; do
while [ ! -S "$sock" ]; do
if [ "$elapsed" -ge "$TIMEOUT" ]; then
echo "postfix-wrapper: timed out after ${TIMEOUT}s waiting for milter socket $sock" >&2
exit 1
fi
sleep "$INTERVAL"
elapsed=$((elapsed + INTERVAL))
done
echo "postfix-wrapper: milter socket ready: $sock"
done
echo "postfix-wrapper: both milter sockets ready, starting postfix"
exec postfix start-fg
+124
View File
@@ -0,0 +1,124 @@
; SelfPost process supervision (spec 4).
;
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens
; the journal-milter socket), then the Postfix wrapper — which additionally
; blocks until both milter sockets are ready before starting Postfix.
;
; If any managed process exhausts its restart retries (FATAL), the crashexit
; event listener brings the whole container down so Docker's restart policy can
; recreate it cleanly, rather than lingering with a dead component.
[supervisord]
nodaemon=true
user=root
logfile=/dev/null
logfile_maxbytes=0
pidfile=/run/supervisord.pid
loglevel=info
; The control socket is reachable by the shared `selfpost` group so the panel
; (running as the unprivileged `panel` user) can ask supervisord to send
; OpenDKIM a reload signal after it rewrites the DKIM tables (spec 6.2). 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.
[unix_http_server]
file=/run/supervisor.sock
chown=root:selfpost
chmod=0770
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///run/supervisor.sock
[program:opendkim]
command=/usr/sbin/opendkim -f -x /etc/opendkim.conf
priority=100
autostart=true
autorestart=true
startretries=3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:panel]
command=/usr/local/bin/panel
user=panel
priority=200
autostart=true
autorestart=true
startretries=3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:postfix]
command=/usr/local/bin/postfix-wrapper.sh
priority=300
autostart=true
autorestart=true
startretries=3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; One-shot reload helper (spec 5.2, 7.2.12). The panel runs unprivileged and
; cannot run `postfix reload` itself, and it cannot signal the Postfix master
; directly: `postfix start-fg` forks a separate master process, so a signal to
; the supervised foreground process never reaches it (unlike OpenDKIM, which
; runs in the foreground as the supervised process itself). Instead the panel
; asks 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.
[program:postfix-reload]
command=/usr/sbin/postfix reload
autostart=false
autorestart=false
startsecs=0
startretries=1
exitcodes=0
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; Periodic TLS-certificate refresh (spec 5.2 p.4). Runs a daily `postfix reload`
; so certificates the reverse-proxy renews in the read-only mount are picked up.
; Long-running (it loops), runs as root so it can reload Postfix, and never exits
; non-zero, so it neither trips the crashexit listener nor needs restarting.
[program:cert-reload]
command=/usr/local/bin/postfix-cert-reload.sh
priority=400
autostart=true
autorestart=true
startsecs=0
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; Periodic logrotate for /var/log/mail.log (spec 9, 10: daily, 7-14 files kept
; in the image). Runs as root so logrotate can read/rotate the log; never exits
; non-zero, so it neither trips the crashexit listener nor needs restarting.
[program:logrotate]
command=/usr/local/bin/logrotate-loop.sh
priority=400
autostart=true
autorestart=true
startsecs=0
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[eventlistener:crashexit]
command=/usr/local/bin/crashexit.py
events=PROCESS_STATE_FATAL
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0