332 lines
11 KiB
Bash
Executable file
332 lines
11 KiB
Bash
Executable file
#!/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
|