#!/usr/bin/env bash # Postfix SUPERVISION layer — restart policy, start-time interface gate, and a # self-heal watchdog. Called by provision-mail.sh; also safe to run standalone # (it touches nothing in main.cf, so it will not fight a mail-config change): # # sudo bash infra/deploy/provision-mail-supervision.sh # # WHY THIS EXISTS — the 2026-07-24 prod outage. # # Postfix treats an `inet_interfaces` address with no matching local interface # as FATAL, and takes down EVERY listener with it — loopback and mesh included, # not just the missing one. prod lists three addresses, and two of them are # created by other daemons: 10.10.0.1 (wg-quick@wg0) and 172.18.0.1 # (docker_gwbridge, created by dockerd). # # The stock postfix@.service is ordered only `After=network-online.target`, # which says nothing about dockerd or wg-quick, and it ships NO `Restart=`. # At the 08:09 reboot that cost 13 hours of mail: # # 08:09:49 postfix@-.service starts # 08:09:51 wg-quick@wg0 finishes <- 10.10.0.1 appears, wins by one second # 08:09:52 fatal: inet_interfaces: no local interface found for 172.18.0.1 # 08:09:53 docker.service only BEGINS starting # 08:10:16 docker.service active <- docker_gwbridge finally exists # # Note this was a BOOT-ORDERING RACE, not a Docker renumbering: 172.18.0.1 is # the pinned docker_gwbridge gateway and has always had that address. Postfix # simply asked for it 24 seconds before dockerd created it. # # Nobody noticed for 13h because `systemctl is-active postfix` said "active" # the whole time — see the umbrella-unit note below. # # THREE LAYERS, because no single one of them is sufficient: # # 1. ORDERING (After=docker.service wg-quick@wg0.service) — makes the race # very unlikely. Not a guarantee: dockerd reporting "active" is not a # promise that a specific bridge is already numbered. # 2. A START-TIME GATE (postfix-wait-interfaces, an ExecStartPre) — makes it # deterministic. It blocks until every address in inet_interfaces actually # exists, so a late interface costs seconds inside one start attempt # instead of burning a restart. This is what makes a BOUNDED start limit # safe; see the StartLimit discussion in the drop-in below. # 3. A WATCHDOG TIMER (postfix-watchdog) — the backstop. It is not redundant # with Restart=: postfix@.service is Type=forking with GuessMainPID=no and # no PIDFile=, so systemd tracks NO main PID for it. `Restart=` therefore # only covers failures during START. If the master process dies at # runtime, systemd does not reliably notice, and no restart policy fires. # The watchdog is the only thing that catches that. # # THE UMBRELLA-UNIT TRAP. `postfix.service` is a wrapper whose ExecStart is # /bin/true and which sets RemainAfterExit=yes. It reports `active (exited)` # forever, whether or not any mail is being handled. The unit that actually # binds sockets is the instance `postfix@-.service`. Never health-check the # umbrella — that is precisely the check that reported green for 13 hours. set -euo pipefail if [[ $EUID -ne 0 ]]; then echo "run as root (sudo)" >&2 exit 1 fi # How long the start-time gate waits for a missing interface before giving up # and letting the restart policy take over. Overridable for testing. POSTFIX_WAIT_TIMEOUT="${POSTFIX_WAIT_TIMEOUT:-60}" echo "==> installing /usr/local/sbin/postfix-wait-interfaces" cat > /usr/local/sbin/postfix-wait-interfaces <<'WAITSCRIPT' #!/usr/bin/env bash # Managed by deploy/provision-mail-supervision.sh -- do not hand-edit. # # ExecStartPre gate for postfix@.service. Blocks until every address listed in # this instance's inet_interfaces actually exists on some local interface. # # Postfix treats a missing inet_interfaces address as fatal and drops ALL its # listeners, so at boot it must not be allowed to ask before dockerd/wg-quick # have finished. Unit ordering makes that unlikely; this makes it deterministic. # # FAIL-OPEN BY DESIGN: if this script cannot introspect the config, it exits 0 # and lets Postfix try anyway. A supervision gate must only ever DELAY a start, # never be the reason mail stays down. set -uo pipefail TIMEOUT="${POSTFIX_WAIT_TIMEOUT:-60}" INSTANCE="${1:--}" # Instance '-' is the default instance and always lives in /etc/postfix. A # second config dir can be passed explicitly; this estate runs one instance. CONFDIR="${2:-/etc/postfix}" log() { echo "postfix-wait-interfaces: $*"; } # MUST be plain `postconf -c`, NOT `postmulti -x postconf`. # # postmulti RESOLVES inet_interfaces, so it fatals with rc=1 and prints nothing # whenever an address is missing -- which is precisely the situation this gate # exists to detect. Using it here made the gate fail open and silently never # wait at all. Plain postconf only reads main.cf and returns the value happily # whether or not the address exists. (Same trap applies to postqueue/postfix; # see postfix-health.) addrs=$(postconf -c "$CONFDIR" -h inet_interfaces 2>/dev/null) || { log "cannot read inet_interfaces from $CONFDIR; not blocking startup" exit 0 } # Only literal IP addresses can be waited for. Keywords (all, loopback-only) # and hostnames are left to Postfix to resolve. want=() for a in ${addrs//,/ }; do case "$a" in all|loopback-only|localhost) continue ;; esac if [[ "$a" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ || "$a" == *:* ]]; then want+=("$a") fi done if (( ${#want[@]} == 0 )); then log "no literal addresses to wait for (inet_interfaces = $addrs)" exit 0 fi deadline=$(( SECONDS + TIMEOUT )) missing=() while :; do present=$(ip -o addr show 2>/dev/null | awk '{print $4}' | cut -d/ -f1) missing=() for a in "${want[@]}"; do grep -qxF "$a" <<<"$present" || missing+=("$a") done (( ${#missing[@]} == 0 )) && break (( SECONDS >= deadline )) && break sleep 1 done if (( ${#missing[@]} == 0 )); then log "all addresses present after ${SECONDS}s: ${want[*]}" exit 0 fi # Deliberately non-zero: let the unit's Restart=/StartLimit policy decide what # happens next, and make the reason visible in the journal. log "TIMEOUT after ${TIMEOUT}s; still missing: ${missing[*]} (inet_interfaces = $addrs)" log "Postfix will fatal on this. Either the interface provider is broken, or" log "inet_interfaces names an address this host no longer has." exit 1 WAITSCRIPT chmod 0755 /usr/local/sbin/postfix-wait-interfaces echo "==> installing /usr/local/sbin/postfix-health" cat > /usr/local/sbin/postfix-health <<'HEALTHSCRIPT' #!/usr/bin/env bash # Managed by deploy/provision-mail-supervision.sh -- do not hand-edit. # # THE canonical "is this box's mail actually working" check. One implementation, # so the watchdog, a human, a Grafana/Loki alert and a future `mail_health` tool # all agree on what healthy means. # # exit 0 = healthy exit 1 = unhealthy # # Output is one logfmt line on stdout (parseable with LogQL `| logfmt`), # followed by human-readable detail on stderr. # # WHAT IT DELIBERATELY DOES NOT DO: check `postfix.service`. That umbrella unit # is ExecStart=/bin/true + RemainAfterExit=yes and reports active(exited) # forever. It reported green through the entire 13h outage of 2026-07-24. set -uo pipefail export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin INSTANCE="${1:--}" CONFDIR="${2:-/etc/postfix}" UNIT="postfix@${INSTANCE}.service" problems=() # --- 1. the INSTANCE unit, never the umbrella -------------------------------- unit_state=$(systemctl is-active "$UNIT" 2>/dev/null || true) [[ "$unit_state" == "active" ]] || problems+=("unit-not-active:$unit_state") # --- 2. every configured address is actually bound on :25 -------------------- # # MUST be plain `postconf -c`, NOT `postmulti -x postconf` / `postqueue`. # # Every Postfix command that RESOLVES inet_interfaces (postmulti, postqueue, # postfix) fatals when one of the addresses is missing from the host -- exactly # the condition this check exists to find. An earlier version of this script # used postmulti, got an empty string back, concluded there were 0 addresses to # verify, and cheerfully reported "status=ok bound=0/0" while main.cf named a # ghost address that would kill all mail at the next reboot. A health check that # fails OPEN is worse than no health check. # # Plain postconf only reads main.cf and never touches an interface. addrs=$(postconf -c "$CONFDIR" -h inet_interfaces 2>/dev/null || echo "") if [[ -z "${addrs// /}" ]]; then problems+=("cannot-read-inet_interfaces") fi listening=$(ss -lnt 2>/dev/null | awk '{print $4}') expected=(); bound=0; unbound=() for a in ${addrs//,/ }; do case "$a" in all) expected+=("0.0.0.0") ;; loopback-only) expected+=("127.0.0.1") ;; localhost) expected+=("127.0.0.1") ;; *) expected+=("$a") ;; esac done for a in "${expected[@]}"; do if grep -qxF "${a}:25" <<<"$listening"; then bound=$(( bound + 1 )); else unbound+=("$a"); fi done (( ${#unbound[@]} == 0 )) || problems+=("not-bound:$(IFS=,; echo "${unbound[*]}")") # Never let "I could not work out what to check" read as healthy. (( ${#expected[@]} > 0 )) || problems+=("no-configured-addresses") # --- 3. LATENT FATAL: config names an address the host does not have --------- # This is the state that survives silently until the next reboot and then kills # all mail. Catching it here turns a future outage into a warning today. present=$(ip -o addr show 2>/dev/null | awk '{print $4}' | cut -d/ -f1) ghosts=() for a in "${expected[@]}"; do [[ "$a" == "0.0.0.0" ]] && continue grep -qxF "$a" <<<"$present" || ghosts+=("$a") done (( ${#ghosts[@]} == 0 )) || problems+=("address-missing-from-host:$(IFS=,; echo "${ghosts[*]}")") # --- 4. a real SMTP greeting, not just an open socket ------------------------ banner="none" if command -v timeout >/dev/null 2>&1; then greeting=$(timeout 5 bash -c 'exec 3<>/dev/tcp/127.0.0.1/25 || exit 1; head -1 <&3; printf "QUIT\r\n" >&3' 2>/dev/null || true) case "$greeting" in 220*) banner="ok" ;; *) banner="bad"; problems+=("no-220-greeting") ;; esac fi # --- 5. SEND-ONLY INVARIANT: must NOT be bound on a public address ----------- # A regression here is worse than an outage: it is an internet-facing MTA. public_ips=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 \ | grep -vE '^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.)' || true) exposed=() for p in $public_ips; do grep -qxF "${p}:25" <<<"$listening" && exposed+=("$p") done grep -qxF "0.0.0.0:25" <<<"$listening" && exposed+=("0.0.0.0") (( ${#exposed[@]} == 0 )) || problems+=("PUBLICLY-BOUND:$(IFS=,; echo "${exposed[*]}")") # --- 6. queue depth and age, excluding the known-benign null-sender bounces --- # Read arrival_time from `postqueue -j`, NOT the queue file's mtime: Postfix # sets a deferred file's mtime to the NEXT RETRY time, which is in the future, # so mtime-based ages come out negative and meaningless. # # Null-sender bounces (postqueue reports sender "MAILER-DAEMON") addressed to # our own no-reply are undeliverable by design -- this host accepts no inbound # mail -- so they sit in the queue until they expire. They must not hold an # alert open forever, so they are excluded from queue_real/oldest. Both the raw # and filtered counts are reported so a monitor can see either. # # NOTE: postqueue RESOLVES inet_interfaces and so fatals (rc=69) in the very # latent-fatal state flagged above. When that happens it yields no JSON, so fall # back to counting spool files directly -- a count that needs no Postfix command # and therefore still works when Postfix itself will not run. queue_json=$(postqueue -j 2>/dev/null || true) read -r queue_total queue_real oldest_age <<<"$( printf '%s\n' "$queue_json" | awk -v now="$(date +%s)" ' NF { total++ sender=""; arr=0 if (match($0, /"sender": "[^"]*"/)) { s = substr($0, RSTART, RLENGTH); gsub(/"sender": "|"/, "", s); sender = s } if (match($0, /"arrival_time": [0-9]+/)) { a = substr($0, RSTART, RLENGTH); gsub(/[^0-9]/, "", a); arr = a + 0 } if (sender != "MAILER-DAEMON" && sender != "") { real++ age = now - arr if (age > oldest) oldest = age } } END { printf "%d %d %d", total + 0, real + 0, oldest + 0 } ' )" queue_total=${queue_total:-0}; queue_real=${queue_real:-0}; oldest_age=${oldest_age:-0} if [[ -z "${queue_json// /}" ]]; then queue_total=$(find /var/spool/postfix/incoming /var/spool/postfix/active \ /var/spool/postfix/deferred -type f 2>/dev/null | wc -l) queue_real=-1 # unknown: cannot classify senders without postqueue fi status=ok; rc=0 (( ${#problems[@]} == 0 )) || { status=fail; rc=1; } printf 'mail_health status=%s unit=%s unit_state=%s bound=%d/%d banner=%s queue=%d queue_real=%d oldest_deferred_s=%d reason=%s\n' \ "$status" "$UNIT" "${unit_state:-unknown}" "$bound" "${#expected[@]}" "$banner" \ "$queue_total" "$queue_real" "$oldest_age" \ "$( (( ${#problems[@]} )) && (IFS=,; echo "${problems[*]}") || echo none )" if (( rc != 0 )); then { echo "--- postfix-health detail ---" echo "inet_interfaces : $addrs" echo "host addresses : $(echo "$present" | tr '\n' ' ')" echo "listening on 25 : $(grep ':25$' <<<"$listening" | tr '\n' ' ')" systemctl status "$UNIT" --no-pager -l 2>/dev/null | head -20 } >&2 fi exit $rc HEALTHSCRIPT chmod 0755 /usr/local/sbin/postfix-health echo "==> installing /usr/local/sbin/postfix-watchdog" cat > /usr/local/sbin/postfix-watchdog <<'WATCHSCRIPT' #!/usr/bin/env bash # Managed by deploy/provision-mail-supervision.sh -- do not hand-edit. # # Self-heal backstop, run every 5 minutes by postfix-watchdog.timer. # # It exists for two failure modes that `Restart=on-failure` provably cannot # cover: # # 1. RUNTIME DEATH. postfix@.service is Type=forking, GuessMainPID=no, and # declares no PIDFile=, so systemd tracks no main PID. If the Postfix # master dies while running, no restart policy fires. # 2. GIVING UP. The unit uses a BOUNDED start limit on purpose, so a broken # config lands in `failed` instead of thrashing forever. Without a # backstop, "failed" would mean "dead until a human looks" — which is the # 13-hour outage again. This retries every 5 minutes, forever, and clears # the start-limit counter so the bound never becomes permanent death. # # Cadence is deliberately 20x slower than RestartSec: fast enough that nothing # is ever down for more than ~5 minutes, slow enough that a genuinely broken # config produces ~288 log lines a day instead of ~5,760. set -uo pipefail export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin INSTANCE=- UNIT="postfix@${INSTANCE}.service" # Escape hatch: `sudo touch /etc/postfix/maintenance` to stop the watchdog # fighting a deliberate `systemctl stop postfix@-`. if [[ -e /etc/postfix/maintenance ]]; then echo "postfix_watchdog action=skipped reason=maintenance-flag" exit 0 fi # Do not interfere with a start already in progress. state=$(systemctl show "$UNIT" -p ActiveState --value 2>/dev/null || echo unknown) if [[ "$state" == "activating" || "$state" == "deactivating" ]]; then echo "postfix_watchdog action=skipped reason=transitional:$state" exit 0 fi if health=$(postfix-health "$INSTANCE" 2>/dev/null); then echo "postfix_watchdog action=ok ${health#mail_health }" exit 0 fi echo "postfix_watchdog action=recovering ${health#mail_health }" # reset-failed clears both the failed state and the start-limit counter, so the # bounded StartLimitBurst can never become a permanent death sentence. systemctl reset-failed "$UNIT" 2>/dev/null || true systemctl start "$UNIT" 2>/dev/null || true sleep 5 if health=$(postfix-health "$INSTANCE" 2>/dev/null); then echo "postfix_watchdog action=recovered ${health#mail_health }" exit 0 fi # Distinctive, greppable line: this is the one that should page a human. echo "postfix_watchdog action=RECOVERY_FAILED ${health#mail_health }" exit 1 WATCHSCRIPT chmod 0755 /usr/local/sbin/postfix-watchdog echo "==> installing postfix@.service drop-in" mkdir -p /etc/systemd/system/postfix@.service.d # Quoted heredoc + a placeholder, NOT shell interpolation: this block contains # backticks and $-signs in its prose, and an unquoted heredoc would run them. cat > /etc/systemd/system/postfix@.service.d/10-wait-for-interfaces.conf <<'DROPIN' # Managed by deploy/provision-mail-supervision.sh -- do not hand-edit. # # A DROP-IN, never an edit to /usr/lib/systemd/system/postfix@.service: the # packaged unit is replaced wholesale by an apt upgrade. # # ORDERING. inet_interfaces may name a Docker bridge gateway and/or a WireGuard # mesh address; neither exists until dockerd / wg-quick has run, and Postfix # treats a missing address as fatal for ALL listeners. Stock ordering is only # After=network-online.target, which says nothing about either daemon. # # Wants=, not Requires=, on purpose. Requires= would mean a dockerd failure # takes mail down entirely — including the loopback and mesh listeners, which # do not depend on Docker at all. That escalates someone else's outage into a # total mail outage. Wants= keeps the ordering when both are present without # making mail's availability a hostage to Docker's. # # THE GATE. ExecStartPre blocks until every configured address actually exists # (bounded by POSTFIX_WAIT_TIMEOUT). Ordering alone is a strong heuristic — # dockerd reporting "active" is not a promise that a specific bridge is # numbered — and this turns the residual race into a deterministic wait. # It is what makes the bounded start limit below safe. # # THE START LIMIT — the deliberate tradeoff. # # StartLimitIntervalSec=0 (retry forever) was considered and rejected. With the # gate in place, the transient case is absorbed INSIDE a single start attempt, # so restarts now overwhelmingly mean "genuinely broken", not "too early". For # that case, retry-forever is the worse option: the unit never reaches `failed` # (so the strongest local signal never exists), and a malformed main.cf # re-fatals every RestartSec forever. # # * 5 attempts / 600s. A broken config reaches `failed` in ~100s (fast # failure: ~5s start + 15s wait) or ~5min (slow failure: the full 60s gate # + 15s wait, 5 times) — either way, loudly, and then it stops. # * Normal operation never approaches this: an isolated crash is 1 restart. # * "Failed" does NOT mean "dead until a human looks" — postfix-watchdog.timer # retries every 5 minutes forever and clears the counter. Bounded fast # retry for transients, slow unbounded retry for everything else. # # TimeoutStartSec covers the worst case: the full @WAIT_TIMEOUT@s gate plus # instance configure plus master start, with headroom. The default 90s would be # uncomfortably tight against a @WAIT_TIMEOUT@s gate. [Unit] After=docker.service wg-quick@wg0.service Wants=docker.service wg-quick@wg0.service StartLimitIntervalSec=600 StartLimitBurst=5 [Service] Environment=POSTFIX_WAIT_TIMEOUT=@WAIT_TIMEOUT@ ExecStartPre=/usr/local/sbin/postfix-wait-interfaces %i TimeoutStartSec=180s Restart=on-failure RestartSec=15s DROPIN sed -i "s/@WAIT_TIMEOUT@/${POSTFIX_WAIT_TIMEOUT}/g" \ /etc/systemd/system/postfix@.service.d/10-wait-for-interfaces.conf echo "==> installing postfix-watchdog.service / .timer" cat > /etc/systemd/system/postfix-watchdog.service <<'WDSERVICE' # Managed by deploy/provision-mail-supervision.sh -- do not hand-edit. [Unit] Description=Postfix health watchdog (self-heal backstop) Documentation=file:///usr/local/sbin/postfix-watchdog # Never race the thing we are supervising. After=postfix@-.service [Service] Type=oneshot ExecStart=/usr/local/sbin/postfix-watchdog # The watchdog failing is itself a signal worth seeing, but it must never be # able to wedge the timer. SuccessExitStatus=0 1 WDSERVICE cat > /etc/systemd/system/postfix-watchdog.timer <<'WDTIMER' # Managed by deploy/provision-mail-supervision.sh -- do not hand-edit. # # Every 5 minutes: the recovery-latency ceiling for mail on this box. The # 2026-07-24 outage ran 13h; this bounds the same failure at ~5min even with # no alerting configured at all. [Unit] Description=Run the Postfix health watchdog every 5 minutes [Timer] OnBootSec=5min OnUnitActiveSec=5min # Spread the load a little so it never lands exactly on other cron-ish work. RandomizedDelaySec=20s AccuracySec=10s [Install] WantedBy=timers.target WDTIMER systemctl daemon-reload systemctl enable --now postfix-watchdog.timer echo "==> supervision layer installed" echo " drop-in : /etc/systemd/system/postfix@.service.d/10-wait-for-interfaces.conf" echo " gate : /usr/local/sbin/postfix-wait-interfaces" echo " health : /usr/local/sbin/postfix-health" echo " watchdog: /usr/local/sbin/postfix-watchdog (postfix-watchdog.timer)"