This repository has been archived on 2026-09-10. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
taler-monitoring/check_mail_deliverability.sh
2026-09-08 01:39:31 +02:00

332 lines
11 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# check_mail_deliverability.sh — Taler mail-plane deliverability (ops-checklist)
#
# SUT domains (NOT cyon): taler.net, gnunet.org, firefly.gnunet.org,
# taler-systems.com, anastasis.lu, taler-ops.ch (from mail-catalog.conf)
#
# Based on admin-log/firefly/ops-checklist.md (2026-03 MT/ED scores):
# dig SPF + DMARC + DKIM selector(s); SMTP RCPT smoke via monitord helper;
# optional IMAP sink poll (mon001–003@hacktivism.ch on cyon = sink only).
#
# Explicit phase (not in all/full):
# ./taler-monitoring.sh mail-deliverability
# ./taler-monitoring.sh maildeliv
#
# Env:
# MAIL_CATALOG default mail-catalog.conf
# MAIL_DKIM_SELECTORS default "mail-2026-3 mail" (space-separated)
# MAIL_DELIV_IMAP=1 enable sink IMAP login+INBOX check (needs pass)
# MAIL_DELIV_IMAP_USERS default mon001@hacktivism.ch mon002@… mon003@…
# MAIL_DELIV_IMAP_HOST default mail.cyon.ch (sink host — not SUT)
# MAIL_DELIV_IMAP_PASS shared password for mon* (prefer secrets.env)
# MAIL_DELIV_SEND=0 1 = authenticated send (Freigabe; needs SMTP_*)
# MAIL_DELIV_ALERT=0 1 = on ERROR, send alert via sink SMTP (Freigabe)
# MAIL_DELIV_ALERT_FROM default mon001@hacktivism.ch
# MAIL_DELIV_ALERT_TO default hernani+mon@taler-systems.com
# hernani+mon@vecirex.net vecirex+mon@icloud.com
# fdold+mon@taler.net
# MAIL_DELIV_ALERT_SMTP default mail.cyon.ch:587 (sink submission — not SUT)
# MAIL_DELIV_ALERT_PASS default MAIL_DELIV_IMAP_PASS
# MAIL_DELIV_ALERT_COOLDOWN_S default 21600 (6h); stamp under monitord state
# TALER_MONITORD_DELIVER_* see site-gen smtp_deliverability_probe
#
set -euo pipefail
ROOT=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=lib.sh
source "$ROOT/lib.sh"
set_area maildeliv
section "maildeliv · Taler mail domains (SPF/DMARC/DKIM + SMTP RCPT; cyon=sink only)"
CATALOG="${MAIL_CATALOG:-$ROOT/mail-catalog.conf}"
SELECTORS="${MAIL_DKIM_SELECTORS:-mail-2026-3 mail}"
: "${MAIL_DELIV_IMAP:=0}"
: "${MAIL_DELIV_SEND:=0}"
: "${MAIL_DELIV_ALERT:=0}"
: "${MAIL_DELIV_PROBE:=0}"
IMAP_HOST="${MAIL_DELIV_IMAP_HOST:-mail.cyon.ch}"
IMAP_USERS="${MAIL_DELIV_IMAP_USERS:-mon001@hacktivism.ch mon002@hacktivism.ch mon003@hacktivism.ch}"
ALERT_FROM="${MAIL_DELIV_ALERT_FROM:-mon001@hacktivism.ch}"
ALERT_TO="${MAIL_DELIV_ALERT_TO:-hernani+mon@taler-systems.com hernani+mon@vecirex.net vecirex+mon@icloud.com fdold+mon@taler.net}"
ALERT_SMTP="${MAIL_DELIV_ALERT_SMTP:-mail.cyon.ch:587}"
ALERT_PASS="${MAIL_DELIV_ALERT_PASS:-${MAIL_DELIV_IMAP_PASS:-}}"
ALERT_COOLDOWN_S="${MAIL_DELIV_ALERT_COOLDOWN_S:-21600}"
STATE_DIR="${TALER_MONITORD_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/taler-monitord}"
ALERT_STAMP="${STATE_DIR}/mail-deliv-alert.stamp"
if [ ! -f "$CATALOG" ]; then
fail "catalog" "missing $CATALOG"
summary
exit 1
fi
info "scope" "SUT = catalog Taler domains · sink IMAP host=$IMAP_HOST (not under test)"
dns_txt() {
local name="$1"
if command -v dig >/dev/null 2>&1; then
dig +short +time=3 +tries=2 TXT "$name" 2>/dev/null | tr -d '"'
else
host -t TXT "$name" 2>/dev/null | sed 's/.*"\(.*\)"/\1/'
fi
}
dns_mx() {
local d="$1"
dig +short +time=3 +tries=2 MX "$d" 2>/dev/null | awk '{print tolower($2)}' | sed 's/\.$//'
}
# --- per catalog domain ---
while IFS= read -r line || [ -n "$line" ]; do
line=${line%%#*}
line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -z "$line" ] && continue
domain=$(echo "$line" | awk '{print $1}')
expect_mx=$(echo "$line" | awk '{print $2}')
label=$(echo "$line" | awk '{print $6}')
[ -n "$domain" ] || continue
set_group "${label:-$domain}"
# SPF
spf=$(dns_txt "$domain" | tr '\n' ' ' | grep -oE 'v=spf1[^"]*' | head -1 || true)
if [ -n "$spf" ]; then
ok "spf" "$domain has SPF" "$spf"
else
fail "spf" "$domain missing SPF TXT"
fi
# DMARC
dmarc=$(dns_txt "_dmarc.$domain" | tr '\n' ' ' | grep -oE 'v=DMARC1.*' | head -1 || true)
if [ -n "$dmarc" ]; then
case "$dmarc" in
*p=none*)
if [ "$domain" = "taler-ops.ch" ] || [ "$domain" = "firefly.gnunet.org" ]; then
warn "dmarc" "$domain DMARC p=none (known weak · ops-checklist)" "$dmarc"
else
warn "dmarc" "$domain DMARC p=none" "$dmarc"
fi
;;
*)
ok "dmarc" "$domain DMARC present" "$dmarc"
;;
esac
else
fail "dmarc" "$domain missing _dmarc TXT"
fi
# DKIM selectors (ops-checklist / 2026-03)
dkim_ok=0
dkim_hit=""
for sel in $SELECTORS; do
[ -n "$sel" ] || continue
txt=$(dns_txt "${sel}._domainkey.${domain}" | tr '\n' ' ')
if echo "$txt" | grep -qiE 'v=DKIM1|p='; then
dkim_ok=1
dkim_hit="$sel"
break
fi
done
if [ "$dkim_ok" = "1" ]; then
ok "dkim" "$domain DKIM selector $dkim_hit"
else
warn "dkim" "$domain no DKIM TXT for selectors [$SELECTORS] (mail-tester often -1)"
fi
# MX matches catalog expectation (first token)
mx_got=$(dns_mx "$domain" | head -1)
exp_first=${expect_mx%%,*}
exp_first=${exp_first%% *}
if [ -n "$mx_got" ]; then
if [ -z "$exp_first" ] || [ "$exp_first" = "-" ] || [ "$mx_got" = "$exp_first" ]; then
ok "mx" "$domain MX=$mx_got"
else
# allow alternate listed in expect_mx comma list
case ",${expect_mx}," in
*",${mx_got},"*) ok "mx" "$domain MX=$mx_got (listed)" ;;
*) warn "mx" "$domain MX=$mx_got want~$exp_first" ;;
esac
fi
else
fail "mx" "$domain no MX"
continue
fi
# SMTP RCPT smoke (no DATA) — reuse site-gen helper
if [ -f "$ROOT/site-gen/stack_status_lib.py" ]; then
out=$(
ROOT="$ROOT" DOMAIN="$domain" MX="$mx_got" python3 - <<'PY' 2>/dev/null || echo "err=import"
import os, sys
sys.path.insert(0, os.path.join(os.environ["ROOT"], "site-gen"))
from stack_status_lib import smtp_deliverability_probe
r = smtp_deliverability_probe(os.environ["MX"], domain=os.environ["DOMAIN"])
print(f"ok={r.get('ok')} light={r.get('light')} reason={r.get('reason')} rcpt={r.get('rcpt')}")
PY
)
case "$out" in
ok=True*)
ok "smtp-rcpt" "$domain $out"
;;
ok=False*light=yellow*|ok=False*rcpt_reject*|ok=False*reason=rcpt_reject*)
# Many MTAs reject postmaster@ from random clients; still useful signal (ops-checklist uses mail-tester for full score).
warn "smtp-rcpt" "$domain $out"
;;
ok=False*)
fail "smtp-rcpt" "$domain $out"
;;
*)
warn "smtp-rcpt" "$domain probe unavailable · $out"
;;
esac
else
info "smtp-rcpt" "skipped (no site-gen/stack_status_lib.py)"
fi
done <"$CATALOG"
# --- optional IMAP sinks (cyon hosts mon*; not SUT) ---
if [ "$MAIL_DELIV_IMAP" = "1" ]; then
set_group sink-imap
if [ -z "${MAIL_DELIV_IMAP_PASS:-}" ]; then
warn "sink-imap" "MAIL_DELIV_IMAP=1 but MAIL_DELIV_IMAP_PASS unset — skip"
else
info "sink-imap" "probing sinks on $IMAP_HOST (infrastructure only · not Taler MTA)"
for user in $IMAP_USERS; do
res=$(
MAIL_DELIV_IMAP_HOST="$IMAP_HOST" MAIL_DELIV_IMAP_USER="$user" \
MAIL_DELIV_IMAP_PASS="$MAIL_DELIV_IMAP_PASS" python3 - <<'PY' 2>/dev/null || echo FAIL
import imaplib, os, ssl
host=os.environ["MAIL_DELIV_IMAP_HOST"]
user=os.environ["MAIL_DELIV_IMAP_USER"]
pw=os.environ["MAIL_DELIV_IMAP_PASS"]
ctx=ssl.create_default_context()
M=imaplib.IMAP4_SSL(host, 993, ssl_context=ctx)
M.login(user, pw)
typ, data=M.select("INBOX")
n=data[0].decode() if data and data[0] else "?"
# also see junk/spam exists
boxes=[]
t, bl=M.list()
for b in bl or []:
s=b.decode(errors="replace")
if "Junk" in s or "spam" in s.lower():
boxes.append("junk/spam")
M.logout()
print(f"OK inbox={n} extra={','.join(boxes) or 'none'}")
PY
)
case "$res" in
OK*) ok "sink" "$user · $res" ;;
*) fail "sink" "$user · $res" ;;
esac
done
fi
else
info "sink-imap" "skipped (MAIL_DELIV_IMAP=0) · sinks mon001–003 for Freigabe send tests"
fi
if [ "$MAIL_DELIV_SEND" = "1" ]; then
warn "send" "MAIL_DELIV_SEND=1 deprecated here — use MAIL_DELIV_PROBE=1 (inbox vs spam)"
fi
if [ "$MAIL_DELIV_PROBE" = "1" ]; then
set_group probe
if [ -z "${MAIL_DELIV_IMAP_PASS:-}${MAIL_DELIV_ALERT_PASS:-}" ]; then
warn "probe" "MAIL_DELIV_PROBE=1 but no IMAP pass"
else
info "probe" "mon* send + IMAP inbox-vs-spam (regular path · host-agent timer 6h)"
if out=$(python3 "$ROOT/mail_deliv_probe.py" 2>&1); then
ok "probe" "all sinks inbox"
info "probe-detail" "$(echo "$out" | tr '\n' ' ' | head -c 240)"
else
ec=$?
echo "$out" | while IFS= read -r line; do
case "$line" in
*result=spam*|*result=both*)
fail "probe-spam" "$line"
;;
*result=missing*|*IMAP_FAIL*|*SEND_FAIL*)
fail "probe-miss" "$line"
;;
SENT*|ALERT*)
info "probe" "$line"
;;
*)
info "probe" "$line"
;;
esac
done
[ "$ec" -eq 0 ] || true
fi
fi
else
info "probe" "skipped (MAIL_DELIV_PROBE=0) · enable for inbox-vs-spam; timer: taler-monitoring-mail-deliv.timer"
fi
# --- alert on ERROR via sink SMTP (cyon submission; recipients = hernani+mon / icloud) ---
maildeliv_alert_if_needed() {
set_group alert
if [ "$MAIL_DELIV_ALERT" != "1" ]; then
info "alert" "skipped (MAIL_DELIV_ALERT=0) · would To: $ALERT_TO"
return 0
fi
if [ "${#ERRORS[@]}" -eq 0 ]; then
ok "alert" "no ERRORs — no alert mail"
return 0
fi
if [ -z "$ALERT_PASS" ]; then
warn "alert" "MAIL_DELIV_ALERT=1 but no MAIL_DELIV_ALERT_PASS / MAIL_DELIV_IMAP_PASS"
return 0
fi
if ! command -v swaks >/dev/null 2>&1; then
warn "alert" "swaks missing — apt/brew install swaks"
return 0
fi
mkdir -p "$STATE_DIR" 2>/dev/null || true
now=$(date +%s)
if [ -f "$ALERT_STAMP" ]; then
prev=$(cat "$ALERT_STAMP" 2>/dev/null || echo 0)
if [ "$prev" -gt 0 ] 2>/dev/null; then
age=$((now - prev))
if [ "$age" -lt "$ALERT_COOLDOWN_S" ]; then
info "alert" "cooldown ${age}s < ${ALERT_COOLDOWN_S}s — skip send"
return 0
fi
fi
fi
smtp_host=${ALERT_SMTP%%:*}
smtp_port=${ALERT_SMTP##*:}
[ "$smtp_port" = "$ALERT_SMTP" ] && smtp_port=587
body=$(mktemp)
{
echo "taler-monitoring mail-deliverability ALERT"
echo "host=$(hostname 2>/dev/null || echo ?)"
echo "when=$(date -Iseconds 2>/dev/null || date)"
echo "errors=${#ERRORS[@]}"
echo ""
for e in "${ERRORS[@]}"; do
echo "- $e"
done
echo ""
echo "SUT = Taler mail domains (not cyon). From=$ALERT_FROM via $ALERT_SMTP"
} >"$body"
subj="[taler-mon maildeliv] ${#ERRORS[@]} ERROR(s)"
send_fail=0
for to in $ALERT_TO; do
if swaks --server "$smtp_host" --port "$smtp_port" \
--auth LOGIN --auth-user "$ALERT_FROM" --auth-password "$ALERT_PASS" \
--tls --from "$ALERT_FROM" --to "$to" \
--header "Subject: $subj" --body "$body" >/dev/null 2>&1; then
ok "alert" "sent → $to"
else
warn "alert" "send failed → $to"
send_fail=1
fi
done
rm -f "$body"
if [ "$send_fail" = "0" ]; then
echo "$now" >"$ALERT_STAMP" 2>/dev/null || true
fi
}
maildeliv_alert_if_needed
summary