release 1.6.0: mail monitoring (firefly + pixel/TSA)
This commit is contained in:
parent
a63db6c8d3
commit
b2aef8aad7
12 changed files with 415 additions and 8 deletions
291
check_mail.sh
Executable file
291
check_mail.sh
Executable file
|
|
@ -0,0 +1,291 @@
|
|||
#!/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
|
||||
Loading…
Add table
Add a link
Reference in a new issue