From 2685ab85d898b66022abca7679cb08a8440f40ba Mon Sep 17 00:00:00 2001 From: Marvin Winkler Date: Mon, 28 Sep 2026 10:43:49 +0200 Subject: [PATCH 1/2] listen on IPv6; fix MariaDB 11 restart loop and dovecot start race IPv6: - entrypoint: inet_protocols=all if the container has IPv6 (Docker network with enable_ipv6), else ipv4; INET_PROTOCOLS overrides - mynetworks default gains [::1]/128 when IPv6 is on - list-available-networks.sh: use connected routes from `ip route` (iproute2 added) instead of parsing ifconfig; emits IPv4 and IPv6 (Postfix [prefix]/len notation). The old parser turned inet6 lines into invalid mynetworks entries on IPv6-enabled networks. - README: INET_PROTOCOLS and an IPv6 section MariaDB 11 (trixie): the process is `mariadbd`, so `killall mysqld` after the first-start DB setup matched nothing. The setup instance kept running unsupervised and runit's mysqld service looped on "A mysqld process already exists" once a second. Use mariadbd-safe and stop the setup instance with `mariadb-admin shutdown`, waiting for the socket to go. Dovecot: create /var/spool/postfix/private before runit starts, so dovecot's auth/lmtp sockets don't fail with "Failed to start listeners" when it wins the race against postfix. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 1 + README.md | 25 ++++++++++++++ scripts/entrypoint.sh | 36 +++++++++++++++++--- scripts/list-available-networks.sh | 54 +++++++++--------------------- 4 files changed, 74 insertions(+), 42 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6aa435b..34bf57a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ RUN apt-get -q -y update \ openssl \ rsyslog \ net-tools \ + iproute2 \ procps \ \ mariadb-client \ diff --git a/README.md b/README.md index a619d14..0574c2f 100644 --- a/README.md +++ b/README.md @@ -119,8 +119,12 @@ __OFFICIAL MAIL ENVIRONMENT VARIABLES__ - POSTFIX_MYDESTINATION - specify the domains which this mail-box handles +- INET_PROTOCOLS + - which IP versions postfix listens on and uses: `all`, `ipv4` or `ipv6` + - _default: auto_ — `all` if the container has IPv6 (Docker network with `enable_ipv6`), otherwise `ipv4` - AUTO_TRUST_NETWORKS - add all networks this container is connected to and trust them to send mails + - includes IPv6 networks (as `[prefix]/len`) when the container has IPv6 - _set to any value to enable_ - ADDITIONAL_MYNETWORKS - add this specific network to the automatically trusted onces @@ -171,6 +175,27 @@ _some characters might brake your configuration!_ _for example: to set_ ___mynetworks_style = subnet___ _just add a environment variable_ ___POSTFIX_RAW_CONFIG_MYNETWORKS_STYLE=subnet___ + +## IPv6 + +Postfix and Dovecot listen on IPv6 whenever the container has it. With +plain Docker defaults a container is IPv4-only, and published ports reach it +over IPv6 through `docker-proxy`, which connects to the container over IPv4 — +so every IPv6 client shows up as the Docker network gateway (`172.x.0.1`). +To see real IPv6 client addresses, give the network IPv6 and let Docker NAT +IPv6 itself (`/etc/docker/daemon.json`: `"ip6tables": true`, plus +`"experimental": true` on Docker < 27): + +```yaml +networks: + default: + enable_ipv6: true + ipam: + config: + - subnet: 172.19.0.0/16 + - subnet: fd00:d0c:25::/64 +``` + ## Volumes - /etc/postfix/tls diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index d2e87d6..736ff0a 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -23,7 +23,25 @@ EOF # chown -R vmail:vmail /var/vmail +## +# POSTFIX IP PROTOCOLS +## + +# Listen on IPv6 too whenever the container has it (Docker network with +# enable_ipv6). IPv4-only containers stay on ipv4 — Postfix would otherwise +# warn about missing IPv6 support on every start. INET_PROTOCOLS overrides. +if [ -z ${INET_PROTOCOLS+x} ]; then + if grep -q . /proc/net/if_inet6 2>/dev/null; then + INET_PROTOCOLS=all + else + INET_PROTOCOLS=ipv4 + fi +fi +echo ">> postfix inet_protocols: $INET_PROTOCOLS" +postconf -e "inet_protocols=$INET_PROTOCOLS" + AVAILABLE_NETWORKS="127.0.0.0/8" +[ "$INET_PROTOCOLS" = "ipv4" ] || AVAILABLE_NETWORKS="$AVAILABLE_NETWORKS,[::1]/128" if [ ! -z ${AUTO_TRUST_NETWORKS+x} ]; then AVAILABLE_NETWORKS=$(list-available-networks.sh | tr '\n' ',' | sed 's/,$//g') echo ">> trust all available networks: $AVAILABLE_NETWORKS" @@ -75,7 +93,7 @@ if [ ! -f "$INITIALIZED" ]; then export MYSQL_USER=dbuser export MYSQL_PASSWORD=dbpassword - /usr/bin/mysqld_safe & + /usr/bin/mariadbd-safe & echo ">> waiting for mysql socket." while [ ! -e "/var/run/mysqld/mysqld.sock" ]; do sleep 1; echo -n "."; done echo ""; echo ">> mysql socket found :)" @@ -88,7 +106,12 @@ if [ ! -f "$INITIALIZED" ]; then sh -c "mysql < /tmp/autocreatedb.mysql && echo '>> db '$MYSQL_DBNAME' successfully installed'; rm /tmp/autocreatedb.mysql; update-database.sh" - killall mysqld + # stop the setup instance before runit starts the supervised one. + # (MariaDB 11 runs as "mariadbd" — the old `killall mysqld` matched + # nothing, left this instance running unsupervised, and runit's mysqld + # service then looped on "A mysqld process already exists" forever.) + mariadb-admin shutdown + while [ -e "/var/run/mysqld/mysqld.sock" ]; do sleep 1; done else echo ">> using '$MYSQL_HOST' as Database Host" @@ -369,8 +392,8 @@ EOF echo ">> RUNIT - create services" mkdir -p /etc/sv/rsyslog /etc/sv/postfix /etc/sv/dovecot /etc/sv/mysqld - echo -e '#!/bin/sh\nexec /usr/bin/mysqld_safe' > /etc/sv/mysqld/run - echo -e '#!/bin/sh\nkillall mysqld' > /etc/sv/mysqld/finish + echo -e '#!/bin/sh\nexec /usr/bin/mariadbd-safe' > /etc/sv/mysqld/run + echo -e '#!/bin/sh\nmariadb-admin shutdown 2>/dev/null || killall -q mariadbd' > /etc/sv/mysqld/finish echo -e '#!/bin/sh\nexec /usr/sbin/rsyslogd -n' > /etc/sv/rsyslog/run @@ -391,6 +414,11 @@ EOF fi +# dovecot's auth + lmtp sockets live in postfix's private dir; postfix only +# creates it when it starts, and runit starts both at once. Create it up +# front so dovecot doesn't fail its first start ("Failed to start listeners"). +install -d -o postfix -g root -m 0700 /var/spool/postfix/private + ## # TLS Cert Renew Stuff ## diff --git a/scripts/list-available-networks.sh b/scripts/list-available-networks.sh index cc109c7..03448d7 100755 --- a/scripts/list-available-networks.sh +++ b/scripts/list-available-networks.sh @@ -1,40 +1,18 @@ -#!/bin/bash +#!/bin/sh +# Print every network this container is directly attached to, one per line, +# in Postfix mynetworks notation (IPv4 a.b.c.d/n, IPv6 [prefix]/n). +# Used by AUTO_TRUST_NETWORKS. +# +# Connected routes are exactly the attached networks, with the host bits +# already zeroed — no netmask arithmetic, and IPv6 works the same way. +# Link-local and multicast IPv6 prefixes are never trusted. -function getNetworkFromAddressAndNetmask { - IFS=. read -r i1 i2 i3 i4 <<< "$1" - IFS=. read -r m1 m2 m3 m4 <<< "$2" - printf "%d.%d.%d.%d\n" "$((i1 & m1))" "$((i2 & m2))" "$((i3 & m3))" "$((i4 & m4))" -} +echo "127.0.0.0/8" +ip -4 route show 2>/dev/null \ + | awk '$1 ~ /\// && $1 != "default" && !/ via / { print $1 }' -function getCidrSuffixFromNetmask { - nbits=0 - IFS=. - for dec in $1 ; do - case $dec in - 255) let nbits+=8;; - 254) let nbits+=7;; - 252) let nbits+=6;; - 248) let nbits+=5;; - 240) let nbits+=4;; - 224) let nbits+=3;; - 192) let nbits+=2;; - 128) let nbits+=1;; - 0);; - *) echo "Error: $dec is not recognised"; exit 1 - esac - done - echo "$nbits" -} - - -IFS=$'\n' # make newlines the only separator -for j in $(ifconfig | grep inet | tr ' ' '\n' | grep 'Mask\|add' | tr '\n' ' ' | sed 's/addr/\naddr/g' | grep . | sed 's/[^0-9. ]//g') -do - ADDR=$(echo "$j" | cut -d' ' -f1) - MASK=$(echo "$j" | cut -d' ' -f2) - - NETWORK=$(getNetworkFromAddressAndNetmask "$ADDR" "$MASK") - CIDR=$(getCidrSuffixFromNetmask "$MASK") - - echo "$NETWORK/$CIDR" -done +grep -q . /proc/net/if_inet6 2>/dev/null || exit 0 +echo "[::1]/128" +ip -6 route show 2>/dev/null \ + | awk '$1 ~ /\// && $1 != "default" && !/ via / && $1 !~ /^(fe80|ff[0-9a-f][0-9a-f]):/ { + split($1, p, "/"); print "[" p[1] "]/" p[2] }' From b8a2c2477082486e769f476bf9332f27c21cdfbc Mon Sep 17 00:00:00 2001 From: Marvin Winkler Date: Mon, 28 Sep 2026 10:54:32 +0200 Subject: [PATCH 2/2] test: wait for postfix as well as dovecot before asserting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both start in parallel under runit. Dovecot now starts cleanly on the first try (no more listener race), so it is often up a few seconds before `service postfix start` has finished — the test then checked for the postfix master too early and failed intermittently in CI. Co-Authored-By: Claude Opus 5.5 --- test.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test.sh b/test.sh index 655adca..f8efacc 100755 --- a/test.sh +++ b/test.sh @@ -19,13 +19,15 @@ echo ">> starting container" docker rm -f "$CN" >/dev/null 2>&1 || true docker run -d --name "$CN" -e MAIL_FQDN=mail01.test.tld "$IMG" >/dev/null -echo ">> waiting for dovecot to listen on 143 (up to 120s)" +# wait for both daemons: they start in parallel under runit, and dovecot is +# usually up a few seconds before postfix's `service postfix start` is done +echo ">> waiting for dovecot (143) and postfix (25) to listen (up to 120s)" up=0 for _ in $(seq 1 60); do - if docker exec "$CN" bash -c 'exec 3<>/dev/tcp/127.0.0.1/143' 2>/dev/null; then up=1; break; fi + if docker exec "$CN" bash -c 'exec 3<>/dev/tcp/127.0.0.1/143 && exec 4<>/dev/tcp/127.0.0.1/25' 2>/dev/null; then up=1; break; fi sleep 2 done -[ "$up" = 1 ] || fail "dovecot did not start listening on 143 in time" +[ "$up" = 1 ] || fail "dovecot/postfix did not start listening on 143/25 in time" echo ">> assert: container is running" [ "$(docker inspect -f '{{.State.Running}}' "$CN")" = true ] || fail "container not running"