#!/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)"
