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