Mail/surface catalogs used non-resolving anastasis.taler-systems.com; point to mail.anastasis.lu (pixel) and anastasis.lu. Stage mon defaults include e2e with francpaysan-secrets + wallet-cli paths.
292 lines
8.3 KiB
Bash
Executable file
292 lines
8.3 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 Anastasis/TSA
|
|
# (mail.anastasis.lu → pixel.taler-systems.com; anastasis.lu).
|
|
#
|
|
# 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
|