thermograph/infra/deploy/provision-mail-supervision.sh

477 lines
20 KiB
Bash
Raw Normal View History

observability: add the estate's first alerting; supervise Postfix The estate had zero alert rules. The only contact point was Grafana's factory default pointing at the literal string <example@email.com>, and beta's untracked compose override routed Grafana's SMTP at prod's Postfix — so alerts, had any existed, would have been delivered by the box most likely to be on fire. Prod served 86 5xx in 24h including five /healthz failures and nobody was told. Alerting (deploys to beta, which is where Grafana runs): - 12 Loki-based rules. There is no Prometheus in this estate and Loki is the only datasource, so every rule is log-derived. - Thresholds come from a 24h backtest that happens to contain a real ~20min prod outage at 04:20Z. Absolute counts, not ratios: prod runs ~7 req/min, so one bad request is 1.4% and a ratio alert would scream all night. The 5xx burst rule fires on the outage's 45 and 22 buckets and on nothing else in the day; the largest benign bucket all day was 5. - Routed to a new private #ops-alerts channel, not to any existing channel — #weather-events, #announcements and #prod are product surfaces that notify real subscribers. - AlertingWatchdog is a dead-man's switch; its value is its absence. - Delivery is proven, not assumed: the rules were provisioned into a throwaway Grafana against beta's live Loki and a real alert arrived in Discord. This matters because GET /api/v1/provisioning/contact-points returns [REDACTED] for the URL — a contact point holding an uninterpolated env var looks perfectly healthy and pages nobody. The only proof is a message arriving. CI gains a structural check, because the existing one only proves YAML parses: an alert rule whose condition names a missing refId is valid YAML, provisions cleanly, and never fires. It also hard-fails on a literal Discord webhook in the repo. Verified against all three breakages deliberately introduced. Postfix supervision: - The 13h outage was a boot-ordering race, not a Docker renumbering: postfix started at 08:09:49, wg0 came up at :51, postfix fataled at :52 on a missing docker_gwbridge address, and dockerd did not finish starting until 08:10:16. Stock postfix@.service is ordered only After=network-online.target and ships no Restart=, so one lost race became a permanent outage. - An ExecStartPre gate now blocks up to 60s until every inet_interfaces address actually exists, which absorbs the transient case inside a single start attempt. That makes bounded retry correct: 5 attempts in 600s, then failed — a genuinely broken config reaches a visible failed state in ~100s instead of re-fataling every 15s forever. - A 5-minute watchdog timer retries indefinitely and runs reset-failed, so "failed" still self-heals. Worst case is ~5 minutes, not 13 hours. - Wants=, not Requires=: a dockerd failure must not take down the loopback and mesh listeners that do not depend on Docker at all. Health checks must read config with `postconf -c`, never postmulti/postqueue/ postfix — those three RESOLVE inet_interfaces and so fatal precisely when an address is missing, which made the first version of this check report status=ok bound=0/0. A health check that fails open is worse than none. DEPLOY.md carries the monitoring contract, including that systemctl is-active postfix is a known-false signal: postfix.service is a wrapper whose ExecStart=/bin/true, so it reports active forever while the real postfix@- instance is failed with zero listeners. Reproduced live. Known gap, documented not fixed: Alloy ships Docker stdout, Caddy files and app JSONL, not journald — so no Postfix line reaches Loki and the mail rules cannot fire until loki.source.journal is added. Claude-Session: https://claude.ai/code/session_0182KTMrsTHJc3TcewCatJFY
2026-07-24 20:19:19 +00:00
#!/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)"