feature/deploy-system #2

Merged
ljp merged 15 commits from feature/deploy-system into main 2025-09-11 17:56:21 +02:00
4 changed files with 381 additions and 228 deletions
Showing only changes of commit d1e28599c9 - Show all commits
+42 -228
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# exitrace - Exim-Log-Trace & Status für einzelne Mails/Adressen/Domain-Ziele
# Maintainer: ljp
# Modular version - requires lib/ directory
# -----------------------------------------------------------------------------
# Features:
# - Sucht in /var/log/easyname/YYYY/MM/YYYY-MM-DD.log(.gz) (anpassbar mit $LOGROOT)
@@ -20,8 +21,26 @@
set -euo pipefail
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Load modules
if [[ -f "$SCRIPT_DIR/lib/date_utils.sh" ]]; then
source "$SCRIPT_DIR/lib/date_utils.sh"
else
echo "Fehler: lib/date_utils.sh nicht gefunden in $SCRIPT_DIR" >&2
exit 1
fi
if [[ -f "$SCRIPT_DIR/lib/mx_lookup.sh" ]]; then
source "$SCRIPT_DIR/lib/mx_lookup.sh"
else
echo "Fehler: lib/mx_lookup.sh nicht gefunden in $SCRIPT_DIR" >&2
exit 1
fi
# Global settings
LOGROOT="${LOGROOT:-/var/log/easyname}"
DATE_FMT="%Y-%m-%d"
TODAY="$(date +%Y-%m-%d)"
usage() {
@@ -60,10 +79,22 @@ DRYRUN=0; WANT_MX=0
while [[ $# -gt 0 ]]; do
case "$1" in
-H|--hours) HOURS="${2:-}"; shift 2 ;;
-DAYS|--days) DAYS="${2:-}"; shift 2 ;;
-d|--date) DATE_ONE="${2:-}"; shift 2 ;;
-r|--range) DATE_RANGE="${2:-}"; shift 2 ;;
-H|--hours)
HOURS="${2:-}"
validate_positive_integer "$HOURS" "--hours"
shift 2 ;;
-DAYS|--days)
DAYS="${2:-}"
validate_positive_integer "$DAYS" "--days"
shift 2 ;;
-d|--date)
DATE_ONE="${2:-}"
validate_date "$DATE_ONE" "--date"
shift 2 ;;
-r|--range)
DATE_RANGE="${2:-}"
validate_date_range "$DATE_RANGE"
shift 2 ;;
-f|--from) FROM="${2:-}"; shift 2 ;;
-t|--to) TO_REGEX="${2:-}"; shift 2 ;;
-D|--domain) TARGET_DOMAIN="${2:-}"; shift 2 ;;
@@ -77,45 +108,6 @@ while [[ $# -gt 0 ]]; do
done
# ------------------------- Datums-Liste bauen --------------------------------
files_for_date() {
local d="$1" y="${d:0:4}" m="${d:5:2}"
local p="${LOGROOT}/${y}/${m}/${d}.log"
if [[ -f "$p" ]]; then
printf "%s\n" "$p"
fi
if [[ -f "${p}.gz" ]]; then
printf "%s\n" "${p}.gz"
fi
}
gen_dates_hours_back() {
local hrs="$1" start_ts end_ts cur
end_ts="$(date +%s)"
start_ts="$(date -d "-${hrs} hours" +%s)"
cur="$(date -d "@$start_ts" +${DATE_FMT})"
while :; do
echo "$cur"
[[ "$(date -d "$cur +1 day" +%s)" -le "$end_ts" ]] || break
cur="$(date -d "$cur +1 day" +${DATE_FMT})"
done
}
gen_dates_days_back() {
local days="$1" i
for ((i=0;i<=days;i++)); do
date -d "-$i day" +${DATE_FMT}
done
}
gen_dates_range() {
local a="${1%%..*}" b="${1##*..}" cur="$a"
while :; do
echo "$cur"
[[ "$cur" == "$b" ]] && break
cur="$(date -d "$cur +1 day" +${DATE_FMT})"
done
}
declare -a DATES=()
if [[ -n "$HOURS" ]]; then
mapfile -t DATES < <(gen_dates_hours_back "$HOURS")
@@ -176,193 +168,15 @@ grep_chain() {
# ------------------------------- MX Block ------------------------------------
if (( WANT_MX )) && [[ -n "$TARGET_DOMAIN" ]]; then
if command -v dig >/dev/null 2>&1; then
echo "== MX für ${TARGET_DOMAIN} =="
dig +short MX "$TARGET_DOMAIN" | sort -n || true
echo "-- A/AAAA der MX:"
while read -r mx; do
host="${mx#* }"
printf "%-35s | " "$host"
dig +short A "$host" | paste -sd, - || true
printf "%-35s | " ""
dig +short AAAA "$host" | paste -sd, - || true
done < <(dig +short MX "$TARGET_DOMAIN" | sort -n)
echo "========================================================================"
else
echo "(Hinweis) dig nicht gefunden, MX-Ausgabe übersprungen."
fi
show_mx_records "$TARGET_DOMAIN"
fi
# ---------------------------- Auswertung (awk) --------------------------------
grep_chain | awk -v TO_RE="${TO_REGEX:-}" -v WANTDOM="${TARGET_DOMAIN:-}" -v MSGIDREQ="${MSGID:-}" -v FROMREQ="${FROM:-}" -v EXTRA="${GREPEXTRA:-}" '
function trim(s){ sub(/^[ \t\r\n]+/, "", s); sub(/[ \t\r\n]+$/, "", s); return s }
function add_rcpt(id, rc){ if (!(rc in rcpts[id])) rcpts[id][rc]=1 }
function incr(a, k){ a[k]++; return a[k] }
if [[ ! -f "$SCRIPT_DIR/lib/log_analyzer.awk" ]]; then
echo "Fehler: lib/log_analyzer.awk nicht gefunden in $SCRIPT_DIR" >&2
exit 1
fi
BEGIN { FS=" " }
{
raw=$0
# Zeit + ID detektieren
match(raw, /[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} [A-Za-z0-9-]+/, t)
if (!t[0]) next
split(t[0], p, " ")
ts = p[1] " " p[2]
sub(/^[0-9-: ]+ /, "", raw_after) # restline
msgid = $1
if (!msgid) next
if (MSGIDREQ != "" && msgid != MSGIDREQ) next
# Init Times
if (!(msgid in first_ts) || ts < first_ts[msgid]) first_ts[msgid]=ts
if (!(msgid in last_ts) || ts > last_ts[msgid]) last_ts[msgid]=ts
# Sender extrahieren
if (index($0, "<=") == 1 || $2 == "<=") {
if (match($0, / from <[^>]+>/, m)) {
s=m[0]; gsub(/.* from </, "", s); gsub(/>.*/, "", s)
from=trim(s)
} else if (match($0, /<= [^ ]+@[^ ]+/, m2)) {
split(m2[0], a, " "); from=trim(a[2])
} else { from="" }
if (from != "") {
from_addr[msgid]=from
if (FROMREQ != "" && from != FROMREQ) from_mismatch[msgid]=1
}
}
# Empfänger extrahieren
rcpt=""
if (match($0, / for <[^>]+>/, r)) {
rcpt=r[0]; gsub(/ for </, "", rcpt); gsub(/>/, "", rcpt); rcpt=trim(rcpt)
} else if (match($0, /\*\* [^ ]+@[^ ]+/, r2)) {
split(r2[0], b, " "); rcpt=trim(b[2])
}
if (rcpt != "") add_rcpt(msgid, rcpt)
# Klassify line types
is_delivery = index($0, " => ")>0
is_fail = index($0, " ** ")>0
is_remote = (index($0, " T=remote_smtp")>0 || index($0, " T=remote_smtp_dkim")>0)
# Success detection: C="250 ..." oder "250 " in C=...
is_success = 0
if (is_delivery && is_remote) {
if (match($0, /C="[^"]*250[^"]*"/)) is_success=1
}
# Deferred detection
# typische Phrasen: "retry time not reached", "temporarily", "Connection timed out", "have been failing"
is_defer = 0
if (is_remote && (index($0, "retry time not reached")>0 || index($0, "temporar")>0 || index($0, "timed out")>0 || match($0, /all hosts for '\''[^']+'\'' have been failing/))) {
is_defer=1
}
# Fail detection (permanent)
# Zeilen mit "** rcpt ..." sind i. d. R. permanent; weitere Marker: "Unrouteable address", "rejected", "mailbox full" kann defer sein -> belassen
is_perm_fail = 0
if (is_fail) {
is_perm_fail=1
} else if (match($0, /(Unrouteable address|no such user|User unknown|550 )/)) {
is_perm_fail=1
}
# Domainfilter (weicher): wir verwerfen die ID nicht, zeigen aber non-matching Zeilen trotzdem mit
if (WANTDOM != "") {
# keine harte Filterung, nur spätere Auswertung nutzt WANTDOM, falls nötig
;
}
# Extra Regex? (nur für Zeilenfilterung, ID bleibt)
if (EXTRA != "" && $0 !~ EXTRA) {
; # wir speichern die Zeile dennoch im Voll-Log, sonst verliert man Kontext
}
# Counters
if (is_success) deliveries[msgid]++
if (is_defer) defers[msgid]++
if (is_perm_fail) fails[msgid]++
# „have been failing …" Marker
if (match($0, /all hosts for '\''[^']+'\'' have been failing/)) {
fail_marker[msgid]=1
}
# Full log sammeln
status[msgid]=(status[msgid] ? status[msgid] "\n" : "") ts " | " $0
}
END {
# IDs einsammeln & sortieren
n=0
for (id in first_ts){ ids[++n]=id }
for (i=2;i<=n;i++){
key=ids[i]; j=i-1
while (j>=1 && first_ts[ids[j]] > first_ts[key]) { ids[j+1]=ids[j]; j-- }
ids[j+1]=key
}
if (n==0){
print "Keine passenden Einträge im gewählten Zeitfenster gefunden."
exit 0
}
sep="------------------------------------------------------------------------"
for (k=1;k<=n;k++){
id=ids[k]
# FROM Nachfilter
if (FROMREQ != "" && from_mismatch[id]==1) {
if (from_addr[id] != FROMREQ && from_addr[id] != "") continue
}
d=(id in deliveries ? deliveries[id] : 0)
e=(id in defers ? defers[id] : 0)
f=(id in fails ? fails[id] : 0)
# Status-Logik:
# - d>0 && f==0 && e==0 -> OK
# - d>0 && (f>0 || e>0) -> PARTIAL
# - d==0 && f>0 -> FAIL
# - d==0 && f==0 && e>0 -> DELAY
# - sonst -> UNKNOWN
status_txt="UNKNOWN"
if (d>0 && f==0 && e==0) status_txt="OK"
else if (d>0 && (f>0 || e>0)) status_txt="PARTIAL"
else if (d==0 && f>0) status_txt="FAIL"
else if (d==0 && f==0 && e>0) status_txt="DELAY"
# Summary-Zeile
# Rcpts sammeln
out_rcpts=""
if (id in rcpts){
for (r in rcpts[id]) {
out_rcpts = (out_rcpts=="" ? r : out_rcpts ", " r)
}
}
printf "[%s] ID=%s | First=%s | From=%s | Rcpts=%s | Delivered=%d Deferred=%d Failed=%d\n",
status_txt, id, first_ts[id], (from_addr[id] ? from_addr[id] : "(?)"), (out_rcpts=="" ? "(?)" : out_rcpts), d, e, f
# Marker-Hinweis
if (fail_marker[id]) {
printf " Note: DELIVERY STALL erkannt: '\''all hosts for domain failing (retry/backoff)'\''\n"
}
# Detailblock
print sep
printf "ID: %s\n", id
printf "First: %s\n", first_ts[id]
printf "Last: %s\n", last_ts[id]
printf "From: %s\n", (from_addr[id] ? from_addr[id] : "(unbekannt)")
printf "Rcpts: %s\n", (out_rcpts=="" ? "(unbekannt)" : out_rcpts)
printf "Status: %s (Delivered=%d, Deferred=%d, Failed=%d)\n", status_txt, d, e, f
print "Log:"
print status[id]
print sep
}
}
'
grep_chain | awk -v TO_RE="${TO_REGEX:-}" -v WANTDOM="${TARGET_DOMAIN:-}" -v MSGIDREQ="${MSGID:-}" -v FROMREQ="${FROM:-}" -v EXTRA="${GREPEXTRA:-}" -f "$SCRIPT_DIR/lib/log_analyzer.awk"
# Built with the help of Claude Code
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# date_utils.sh - Date utility functions for exitrace
# Maintainer: ljp
# Constants
readonly DATE_FMT="%Y-%m-%d"
readonly MAX_DAYS_LIMIT=400
# Validation functions
validate_positive_integer() {
local value="$1"
local param="$2"
if [[ -z "$value" ]]; then
echo "Fehler: Parameter $param benötigt einen Wert" >&2
exit 1
fi
if ! [[ "$value" =~ ^[0-9]+$ ]]; then
echo "Fehler: $param muss eine positive Zahl sein (gegeben: '$value')" >&2
exit 1
fi
if [[ "$value" -eq 0 ]]; then
echo "Fehler: $param muss größer als 0 sein" >&2
exit 1
fi
}
validate_date() {
local date_val="$1"
local param="$2"
if [[ -z "$date_val" ]]; then
echo "Fehler: Parameter $param benötigt einen Wert" >&2
exit 1
fi
if ! date -d "$date_val" >/dev/null 2>&1; then
echo "Fehler: Ungültiges Datumsformat für $param: '$date_val'" >&2
echo "Erwartetes Format: YYYY-MM-DD" >&2
exit 1
fi
}
validate_date_range() {
local range="$1"
if [[ -z "$range" ]]; then
echo "Fehler: --range benötigt einen Wert" >&2
exit 1
fi
if [[ "$range" != *".."* ]]; then
echo "Fehler: Ungültiges Range-Format: '$range'" >&2
echo "Erwartetes Format: YYYY-MM-DD..YYYY-MM-DD" >&2
exit 1
fi
local start_date="${range%%..*}"
local end_date="${range##*..}"
validate_date "$start_date" "--range Startdatum"
validate_date "$end_date" "--range Enddatum"
}
# Date generation functions
gen_dates_hours_back() {
local hrs="$1" start_ts end_ts cur next_ts
end_ts="$(date +%s)" || { echo "Fehler: Kann aktuellen Timestamp nicht ermitteln" >&2; exit 1; }
start_ts="$(date -d "-${hrs} hours" +%s)" || { echo "Fehler: Kann Start-Timestamp nicht berechnen" >&2; exit 1; }
cur="$(date -d "@$start_ts" +${DATE_FMT})" || { echo "Fehler: Kann Startdatum nicht formatieren" >&2; exit 1; }
local iterations=0
while :; do
echo "$cur"
next_ts="$(date -d "$cur +1 day" +%s 2>/dev/null)" || { echo "Fehler: Datumsberechnung fehlgeschlagen für $cur" >&2; exit 1; }
[[ "$next_ts" -le "$end_ts" ]] || break
cur="$(date -d "$cur +1 day" +${DATE_FMT} 2>/dev/null)" || { echo "Fehler: Datum-Inkrement fehlgeschlagen für $cur" >&2; exit 1; }
# Sicherheit gegen Endlosschleifen
((iterations++))
if [[ $iterations -gt $MAX_DAYS_LIMIT ]]; then
echo "Fehler: Zu viele Tage im Zeitfenster (>$MAX_DAYS_LIMIT Tage)" >&2
exit 1
fi
done
}
gen_dates_days_back() {
local days="$1" i
for ((i=0;i<=days;i++)); do
date -d "-$i day" +${DATE_FMT} || { echo "Fehler: Kann Datum für -$i day nicht berechnen" >&2; exit 1; }
done
}
gen_dates_range() {
local a="${1%%..*}" b="${1##*..}" cur="$a"
local iterations=0
while :; do
echo "$cur"
[[ "$cur" == "$b" ]] && break
cur="$(date -d "$cur +1 day" +${DATE_FMT} 2>/dev/null)" || { echo "Fehler: Datum-Inkrement fehlgeschlagen für $cur" >&2; exit 1; }
# Sicherheit gegen Endlosschleifen
((iterations++))
if [[ $iterations -gt $MAX_DAYS_LIMIT ]]; then
echo "Fehler: Zu großer Datumsbereich (>$MAX_DAYS_LIMIT Tage)" >&2
exit 1
fi
done
}
files_for_date() {
local d="$1" y="${d:0:4}" m="${d:5:2}"
local logroot="${LOGROOT:-/var/log/easyname}"
local p="${logroot}/${y}/${m}/${d}.log"
if [[ -f "$p" ]]; then
printf "%s\n" "$p"
fi
if [[ -f "${p}.gz" ]]; then
printf "%s\n" "${p}.gz"
fi
}
# Built with the help of Claude Code
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/awk -f
# log_analyzer.awk - Exim log analyzer for exitrace
# Maintainer: ljp
# Processes Exim logs and classifies delivery status per Message-ID
function trim(s){ sub(/^[ \t\r\n]+/, "", s); sub(/[ \t\r\n]+$/, "", s); return s }
function add_rcpt(id, rc){ if (!(rc in rcpts[id])) rcpts[id][rc]=1 }
function incr(a, k){ a[k]++; return a[k] }
BEGIN { FS=" " }
{
raw=$0
# Zeit + ID detektieren
match(raw, /[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} [A-Za-z0-9-]+/, t)
if (!t[0]) next
split(t[0], p, " ")
ts = p[1] " " p[2]
# Extrahiere Rest der Zeile nach Timestamp für weitere Verarbeitung
raw_after = raw
sub(/^[^:]*:[^:]*:[^ ]+ /, "", raw_after)
msgid = $1
if (!msgid) next
if (MSGIDREQ != "" && msgid != MSGIDREQ) next
# Init Times
if (!(msgid in first_ts) || ts < first_ts[msgid]) first_ts[msgid]=ts
if (!(msgid in last_ts) || ts > last_ts[msgid]) last_ts[msgid]=ts
# Sender extrahieren
if (index($0, "<=") == 1 || $2 == "<=") {
if (match($0, / from <[^>]+>/, m)) {
s=m[0]; gsub(/.* from </, "", s); gsub(/>.*/, "", s)
from=trim(s)
} else if (match($0, /<= [^ ]+@[^ ]+/, m2)) {
split(m2[0], a, " "); from=trim(a[2])
} else { from="" }
if (from != "") {
from_addr[msgid]=from
if (FROMREQ != "" && from != FROMREQ) from_mismatch[msgid]=1
}
}
# Empfänger extrahieren
rcpt=""
if (match($0, / for <[^>]+>/, r)) {
rcpt=r[0]; gsub(/ for </, "", rcpt); gsub(/>/, "", rcpt); rcpt=trim(rcpt)
} else if (match($0, /\*\* [^ ]+@[^ ]+/, r2)) {
split(r2[0], b, " "); rcpt=trim(b[2])
}
if (rcpt != "") add_rcpt(msgid, rcpt)
# Klassify line types
is_delivery = index($0, " => ")>0
is_fail = index($0, " ** ")>0
is_remote = (index($0, " T=remote_smtp")>0 || index($0, " T=remote_smtp_dkim")>0)
# Success detection: C="250 ..." oder "250 " in C=...
is_success = 0
if (is_delivery && is_remote) {
if (match($0, /C="[^"]*250[^"]*"/)) is_success=1
}
# Deferred detection
# typische Phrasen: "retry time not reached", "temporarily", "Connection timed out", "have been failing"
is_defer = 0
if (is_remote && (index($0, "retry time not reached")>0 || index($0, "temporar")>0 || index($0, "timed out")>0 || match($0, /all hosts for '\''[^']+'\'' have been failing/))) {
is_defer=1
}
# Fail detection (permanent)
# Zeilen mit "** rcpt ..." sind i. d. R. permanent; weitere Marker: "Unrouteable address", "rejected", "mailbox full" kann defer sein -> belassen
is_perm_fail = 0
if (is_fail) {
is_perm_fail=1
} else if (match($0, /(Unrouteable address|no such user|User unknown|550 )/)) {
is_perm_fail=1
}
# Domainfilter (weicher): wir verwerfen die ID nicht, zeigen aber non-matching Zeilen trotzdem mit
if (WANTDOM != "") {
# keine harte Filterung, nur spätere Auswertung nutzt WANTDOM, falls nötig
;
}
# Extra Regex? (nur für Zeilenfilterung, ID bleibt)
if (EXTRA != "" && $0 !~ EXTRA) {
; # wir speichern die Zeile dennoch im Voll-Log, sonst verliert man Kontext
}
# Counters
if (is_success) deliveries[msgid]++
if (is_defer) defers[msgid]++
if (is_perm_fail) fails[msgid]++
# „have been failing …" Marker
if (match($0, /all hosts for '\''[^']+'\'' have been failing/)) {
fail_marker[msgid]=1
}
# Full log sammeln
status[msgid]=(status[msgid] ? status[msgid] "\n" : "") ts " | " $0
}
END {
# IDs einsammeln & sortieren
n=0
for (id in first_ts){ ids[++n]=id }
for (i=2;i<=n;i++){
key=ids[i]; j=i-1
while (j>=1 && first_ts[ids[j]] > first_ts[key]) { ids[j+1]=ids[j]; j-- }
ids[j+1]=key
}
if (n==0){
print "Keine passenden Einträge im gewählten Zeitfenster gefunden."
exit 0
}
sep="------------------------------------------------------------------------"
for (k=1;k<=n;k++){
id=ids[k]
# FROM Nachfilter
if (FROMREQ != "" && from_mismatch[id]==1) {
if (from_addr[id] != FROMREQ && from_addr[id] != "") continue
}
d=(id in deliveries ? deliveries[id] : 0)
e=(id in defers ? defers[id] : 0)
f=(id in fails ? fails[id] : 0)
# Status-Logik:
# - d>0 && f==0 && e==0 -> OK
# - d>0 && (f>0 || e>0) -> PARTIAL
# - d==0 && f>0 -> FAIL
# - d==0 && f==0 && e>0 -> DELAY
# - sonst -> UNKNOWN
status_txt="UNKNOWN"
if (d>0 && f==0 && e==0) status_txt="OK"
else if (d>0 && (f>0 || e>0)) status_txt="PARTIAL"
else if (d==0 && f>0) status_txt="FAIL"
else if (d==0 && f==0 && e>0) status_txt="DELAY"
# Summary-Zeile
# Rcpts sammeln
out_rcpts=""
if (id in rcpts){
for (r in rcpts[id]) {
out_rcpts = (out_rcpts=="" ? r : out_rcpts ", " r)
}
}
printf "[%s] ID=%s | First=%s | From=%s | Rcpts=%s | Delivered=%d Deferred=%d Failed=%d\n",
status_txt, id, first_ts[id], (from_addr[id] ? from_addr[id] : "(?)"), (out_rcpts=="" ? "(?)" : out_rcpts), d, e, f
# Marker-Hinweis
if (fail_marker[id]) {
printf " Note: DELIVERY STALL erkannt: '\''all hosts for domain failing (retry/backoff)'\''\n"
}
# Detailblock
print sep
printf "ID: %s\n", id
printf "First: %s\n", first_ts[id]
printf "Last: %s\n", last_ts[id]
printf "From: %s\n", (from_addr[id] ? from_addr[id] : "(unbekannt)")
printf "Rcpts: %s\n", (out_rcpts=="" ? "(unbekannt)" : out_rcpts)
printf "Status: %s (Delivered=%d, Deferred=%d, Failed=%d)\n", status_txt, d, e, f
print "Log:"
print status[id]
print sep
}
}
# Built with the help of Claude Code
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# mx_lookup.sh - MX record lookup functions for exitrace
# Maintainer: ljp
# MX record lookup with A/AAAA resolution
show_mx_records() {
local domain="$1"
if ! command -v dig >/dev/null 2>&1; then
echo "(Hinweis) dig nicht gefunden, MX-Ausgabe übersprungen." >&2
return 1
fi
echo "== MX für ${domain} =="
dig +short MX "$domain" | sort -n || {
echo "Fehler: MX-Abfrage für $domain fehlgeschlagen" >&2
return 1
}
echo "-- A/AAAA der MX:"
while read -r mx; do
[[ -n "$mx" ]] || continue
local host="${mx#* }"
printf "%-35s | " "$host"
# A records
local a_records
a_records="$(dig +short A "$host" 2>/dev/null | paste -sd, -)"
printf "%s" "${a_records:-"-"}"
printf " | "
# AAAA records
local aaaa_records
aaaa_records="$(dig +short AAAA "$host" 2>/dev/null | paste -sd, -)"
printf "%s\n" "${aaaa_records:-"-"}"
done < <(dig +short MX "$domain" | sort -n)
echo "========================================================================"
}
# Built with the help of Claude Code