taler-monitoring/check_mail.sh
2026-07-19 00:58:07 +02:00

291 lines
8.2 KiB
Bash
Executable file

#!/usr/bin/env bash
# check_mail.sh — outside-in mail (MX / SMTP / IMAP / SPF / DMARC)
#
# Catalog: mail-catalog.conf (MAIL_CATALOG=… to override)
# Covers firefly (taler.net, gnunet.org) and pixel (taler-systems.com, …).
#
# Outside-only. Phase: mail
#
set -euo pipefail
ROOT=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=lib.sh
source "$ROOT/lib.sh"
set_area mail
section "mail · MX / SMTP / IMAP / SPF / DMARC (outside-in)"
CATALOG="${MAIL_CATALOG:-$ROOT/mail-catalog.conf}"
PORT_TIMEOUT="${MAIL_PORT_TIMEOUT:-4}"
SMTP_TIMEOUT="${MAIL_SMTP_TIMEOUT:-10}"
if [ ! -f "$CATALOG" ]; then
fail "catalog" "missing $CATALOG"
exit 1
fi
# dig or host fallback
dns_mx() {
local d="$1"
if command -v dig >/dev/null 2>&1; then
dig +short +time=3 +tries=2 MX "$d" 2>/dev/null | awk '{print tolower($2)}' | sed 's/\.$//'
else
host -t MX "$d" 2>/dev/null | awk -F'in mail is |has address ' '/mail is/{print tolower($NF)}' | sed 's/\.$//'
fi
}
dns_a() {
local h="$1"
if command -v dig >/dev/null 2>&1; then
dig +short +time=3 +tries=2 A "$h" 2>/dev/null | grep -E '^[0-9.]+$' || true
dig +short +time=3 +tries=2 AAAA "$h" 2>/dev/null | grep -E ':' || true
else
getent ahosts "$h" 2>/dev/null | awk '{print $1}' | sort -u
fi
}
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
}
tcp_open() {
local h="$1" p="$2"
if command -v timeout >/dev/null 2>&1; then
timeout "$PORT_TIMEOUT" bash -c "echo >/dev/tcp/${h}/${p}" 2>/dev/null
else
bash -c "echo >/dev/tcp/${h}/${p}" 2>/dev/null
fi
}
# SMTP: read banner first, then EHLO (avoids "protocol synchronization")
smtp_probe() {
local host="$1" port="$2"
MAIL_HOST="$host" MAIL_PORT="$port" MAIL_TO="${SMTP_TIMEOUT}" python3 - <<'PY' 2>/dev/null
import os, socket, sys
host = os.environ["MAIL_HOST"]
port = int(os.environ["MAIL_PORT"])
to = float(os.environ.get("MAIL_TO", "10"))
try:
s = socket.create_connection((host, port), to)
s.settimeout(to)
banner = s.recv(1024).decode("utf-8", "replace").strip().split("\n")[0]
if not banner.startswith("220"):
print(f"bad_banner={banner[:80]}")
sys.exit(1)
s.sendall(b"EHLO taler-monitoring.invalid\r\n")
data = b""
while True:
chunk = s.recv(4096)
if not chunk:
break
data += chunk
if b"\n" in chunk and (data.endswith(b"\r\n") or len(data) > 8000):
# multi-line 250-… ends with 250 space
lines = data.decode("utf-8", "replace").splitlines()
if any(l.startswith("250 ") for l in lines):
break
if any(l.startswith("5") for l in lines[:3]):
break
text = data.decode("utf-8", "replace")
s.sendall(b"QUIT\r\n")
try:
s.recv(256)
except Exception:
pass
s.close()
starttls = "starttls" if "STARTTLS" in text.upper() else "no_starttls"
print(f"banner={banner[:60]} · {starttls}")
sys.exit(0)
except Exception as e:
print(f"err={e}")
sys.exit(1)
PY
}
# IMAP unencrypted greeting on 143, or just TCP on 993 (TLS)
imap_probe() {
local host="$1" port="$2"
if [ "$port" = "993" ] || [ "$port" = "995" ]; then
if tcp_open "$host" "$port"; then
echo "tcp_open tls_port=$port"
return 0
fi
return 1
fi
MAIL_HOST="$host" MAIL_PORT="$port" MAIL_TO="${SMTP_TIMEOUT}" python3 - <<'PY' 2>/dev/null
import os, socket, sys
host = os.environ["MAIL_HOST"]
port = int(os.environ["MAIL_PORT"])
to = float(os.environ.get("MAIL_TO", "10"))
try:
s = socket.create_connection((host, port), to)
s.settimeout(to)
banner = s.recv(1024).decode("utf-8", "replace").strip().split("\n")[0]
s.sendall(b"a001 LOGOUT\r\n")
try:
s.recv(256)
except Exception:
pass
s.close()
if banner.upper().startswith("* OK") or "IMAP" in banner.upper():
print(f"banner={banner[:70]}")
sys.exit(0)
print(f"unexpected={banner[:70]}")
sys.exit(1)
except Exception as e:
print(f"err={e}")
sys.exit(1)
PY
}
info "catalog" "$CATALOG"
# Track probed hosts to avoid duplicate SMTP checks
declare -A HOST_DONE=()
while IFS= read -r line || [ -n "$line" ]; do
line=${line%%#*}
line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -z "$line" ] && continue
# shellcheck disable=SC2086
set -- $line
domain=$1
expect_mx=${2:-}
mail_hosts=${3:-}
smtp_ports=${4:-25,465,587}
imap_ports=${5:-143,993}
label=${6:-$domain}
set_group "$label"
info "domain" "$domain · expected_mx=$expect_mx · hosts=$mail_hosts"
# --- MX ---
mapfile -t mx_hosts < <(dns_mx "$domain" | sed '/^$/d')
if [ "${#mx_hosts[@]}" -eq 0 ]; then
fail "mx" "$domain has no MX records"
else
ok "mx" "$domain MX → ${mx_hosts[*]}"
# expected MX match (suffix / exact)
if [ -n "$expect_mx" ]; then
matched=0
IFS=',' read -ra want_list <<<"$expect_mx"
for w in "${want_list[@]}"; do
w=$(echo "$w" | tr '[:upper:]' '[:lower:]' | sed 's/\.$//')
for m in "${mx_hosts[@]}"; do
m=$(echo "$m" | tr '[:upper:]' '[:lower:]')
case "$m" in
"$w"|"$w".*|*."$w") matched=1; break ;;
esac
[ "$m" = "$w" ] && matched=1
done
[ "$matched" = "1" ] && break
done
if [ "$matched" = "1" ]; then
ok "mx expected" "$domain MX matches catalog ($expect_mx)"
else
fail "mx expected" "$domain MX ${mx_hosts[*]} · want one of: $expect_mx"
fi
fi
fi
# --- SPF / DMARC ---
spf=$(dns_txt "$domain" | tr '\n' ' ')
if echo "$spf" | grep -qi 'v=spf1'; then
ok "spf" "$domain has SPF"
else
warn "spf" "$domain no SPF TXT (v=spf1)"
fi
dmarc=$(dns_txt "_dmarc.$domain" | tr '\n' ' ')
if echo "$dmarc" | grep -qi 'v=dmarc1'; then
ok "dmarc" "$domain has DMARC"
else
warn "dmarc" "$domain no DMARC at _dmarc.$domain"
fi
# --- mail hosts: DNS + ports + banners ---
IFS=',' read -ra hosts <<<"$mail_hosts"
for h in "${hosts[@]}"; do
h=$(echo "$h" | tr '[:upper:]' '[:lower:]' | sed 's/\.$//;s/^[[:space:]]*//;s/[[:space:]]*$//')
[ -z "$h" ] && continue
addrs=$(dns_a "$h" | tr '\n' ' ')
if [ -z "${addrs// }" ]; then
fail "dns" "$h does not resolve (A/AAAA)"
continue
fi
ok "dns" "$h$addrs"
# skip full protocol re-probe if already done
if [ "${HOST_DONE[$h]:-}" = "1" ]; then
info "host" "$h already probed this run"
continue
fi
HOST_DONE[$h]=1
IFS=',' read -ra sports <<<"$smtp_ports"
for p in "${sports[@]}"; do
p=${p// /}
[ -z "$p" ] && continue
if ! tcp_open "$h" "$p"; then
# 587 optional on pixel
case "$p" in
587) warn "smtp port" "$h:$p closed/filtered (submission)" ;;
*) fail "smtp port" "$h:$p closed/filtered" ;;
esac
continue
fi
case "$p" in
465)
ok "smtp port" "$h:$p open (SMTPS)"
;;
25|587)
if detail=$(smtp_probe "$h" "$p"); then
ok "smtp" "$h:$p $detail"
else
# port open but banner failed — still ERROR for 25
if [ "$p" = "25" ]; then
fail "smtp" "$h:$p open but SMTP handshake failed · $detail"
else
warn "smtp" "$h:$p open but handshake failed · $detail"
fi
fi
;;
*)
ok "smtp port" "$h:$p open"
;;
esac
done
IFS=',' read -ra iports <<<"$imap_ports"
for p in "${iports[@]}"; do
p=${p// /}
[ -z "$p" ] && continue
if ! tcp_open "$h" "$p"; then
fail "imap port" "$h:$p closed/filtered"
continue
fi
case "$p" in
993|995)
ok "imap port" "$h:$p open (TLS)"
;;
143|110)
if detail=$(imap_probe "$h" "$p"); then
ok "imap" "$h:$p $detail"
else
warn "imap" "$h:$p open but greeting failed · $detail"
fi
;;
*)
ok "imap port" "$h:$p open"
;;
esac
done
done
done <"$CATALOG"
info "hint" "firefly = taler.net/gnunet.org MX; pixel = taler-systems.com (mail.anastasis.lu / mail.taler-systems.com)"
exit 0