#!/usr/bin/env bash # exitrace - Remote Exim-Log-Trace & Status für einzelne Mails/Adressen/Domain-Ziele # Maintainer: ljp # ----------------------------------------------------------------------------- # Features: # - Läuft lokal auf logger.in.nessus.at (wo die Exim-Logs liegen) # - Sucht in /var/log/easyname/YYYY/MM/YYYY-MM-DD.log(.gz) # - Zeitfenster: letzte N Stunden/Tage ODER konkretes Datum/Spanne # - Filter: Absender (-FROM), Empfänger-Regex (-TO), Ziel-Domain (-DOMAIN), Message-ID (-MSGID), Extra-Regex (-GREP) # - Gruppiert Treffer pro Exim-Message-ID und fasst Status zusammen # - Klassifikation: OK / PARTIAL / DELAY / FAIL / UNKNOWN # - Optional: MX-Übersicht für -DOMAIN mittels --mx # # Deployment: Mit deploy.sh auf logger.in.nessus.at installieren # Usage: ssh logger.in.nessus.at exitrace [OPTS] # # Beispiele: # exitrace -FROM sender@example.com -DOMAIN example.de -H 48 # exitrace -MSGID 1uwRvz-000Fz2-CI # exitrace -TO '.*@example.de' -r 2025-09-09..2025-09-11 --mx # # Exitcodes: 0=OK (Treffer evtl. 0), 1=Fehlerhafte Parameter/Umgebung # ----------------------------------------------------------------------------- set -euo pipefail # Standardwerte HOURS="" DAYS="" DATE="" RANGE="" FROM="" TO="" DOMAIN="" MSGID="" GREP="" MX=0 DRY_RUN=0 usage() { cat <<'USAGE' Usage: exitrace [OPTS] Zeitfenster (eine Option wählen): -H, --hours N Letzte N Stunden durchsuchen -DAYS, --days N Letzte N Tage durchsuchen -d, --date YYYY-MM-DD Exaktes Datum -r, --range A..B Datumsbereich (inkl.): YYYY-MM-DD..YYYY-MM-DD Filter (beliebig kombinierbar): -FROM, --from ADDR Envelope-From / "from <...>" (alias: -f) -TO, --to REGEX Empfänger (Regex, matcht ' for ' oder in '** rcpt') (alias: -t) -DOMAIN, --domain NAME Ziel-Domain (z. B. example.de) (alias: -D) -MSGID, --msgid ID Exim-Message-ID (z. B. 1uwRvz-000Fz2-CI) (alias: -m) -GREP, --grep REGEX Zusätzlicher Regex auf Logzeilen (alias: -G) Sonstiges: --mx Zeigt MX-Einträge (und A/AAAA) für --domain -n, --dry-run Zeigt nur, welche Logfiles untersucht würden -h, --help Diese Hilfe Beispiele: exitrace -FROM sender@example.com -DOMAIN example.de -H 48 exitrace -MSGID 1uwRvz-000Fz2-CI exitrace -TO '.*@example.de' -r 2025-09-09..2025-09-11 --mx Backwards Compatibility: exitrace -f sender@domain.com -D example.de -H 48 exitrace -m 1uwRvz-000Fz2-CI exitrace -t '.*@gmail\\.com' --mx USAGE } # Parse Arguments while [[ $# -gt 0 ]]; do case $1 in -H|--hours) HOURS="$2" [[ "$HOURS" =~ ^[0-9]+$ ]] || { echo "Fehler: --hours benötigt eine Zahl" >&2; exit 1; } shift 2 ;; -DAYS|--days) DAYS="$2" [[ "$DAYS" =~ ^[0-9]+$ ]] || { echo "Fehler: --days benötigt eine Zahl" >&2; exit 1; } shift 2 ;; -d|--date) DATE="$2" [[ "$DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { echo "Fehler: --date benötigt YYYY-MM-DD" >&2; exit 1; } shift 2 ;; -r|--range) RANGE="$2" [[ "$RANGE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}\.\.[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { echo "Fehler: --range benötigt YYYY-MM-DD..YYYY-MM-DD" >&2; exit 1; } shift 2 ;; -FROM|--from|-f) FROM="$2" shift 2 ;; -TO|--to|-t) TO="$2" shift 2 ;; -DOMAIN|--domain|-D) DOMAIN="$2" shift 2 ;; -MSGID|--msgid|-m) MSGID="$2" shift 2 ;; -GREP|--grep|-G) GREP="$2" shift 2 ;; --mx) MX=1 shift ;; -n|--dry-run) DRY_RUN=1 shift ;; -h|--help) usage exit 0 ;; *) echo "Unbekannte Option: $1" >&2 usage exit 1 ;; esac done # Validierung: Nur ein Zeitfenster time_options=0 [[ -n "$HOURS" ]] && ((time_options++)) [[ -n "$DAYS" ]] && ((time_options++)) [[ -n "$DATE" ]] && ((time_options++)) [[ -n "$RANGE" ]] && ((time_options++)) if [[ $time_options -gt 1 ]]; then echo "Fehler: Nur ein Zeitfenster (-H/-DAYS/-d/-r) erlaubt" >&2 exit 1 fi # Default: Heutiges Datum if [[ $time_options -eq 0 ]]; then DATE=$(date +%Y-%m-%d) fi # Datum-Validierung für DATE validate_date() { local d="$1" if ! date -d "$d" >/dev/null 2>&1; then echo "Fehler: Ungültiges Datum: $d" >&2 exit 1 fi } # Bestimme Logfiles basierend auf Zeitfenster determine_logfiles() { local logfiles=() if [[ -n "$DATE" ]]; then validate_date "$DATE" local year month year=$(date -d "$DATE" +%Y) month=$(date -d "$DATE" +%m) logfiles+=("/var/log/easyname/$year/$month/$DATE.log") elif [[ -n "$RANGE" ]]; then local start_date end_date start_date="${RANGE%%..*}" end_date="${RANGE##*..}" validate_date "$start_date" validate_date "$end_date" local current_date="$start_date" # Hinweis: In [[ ]] gibt es kein '<=' String-Operator. # Daher explizit 'kleiner als ODER gleich' prüfen. while [[ "$current_date" < "$end_date" || "$current_date" == "$end_date" ]]; do local year month year=$(date -d "$current_date" +%Y) month=$(date -d "$current_date" +%m) logfiles+=("/var/log/easyname/$year/$month/$current_date.log") current_date=$(date -d "$current_date + 1 day" +%Y-%m-%d) done elif [[ -n "$HOURS" ]]; then # Bestimme Datumsbereich für letzte N Stunden local start_ts end_ts end_ts=$(date +%s) start_ts=$((end_ts - HOURS * 3600)) local current_ts="$start_ts" local seen_dates=() while [[ $current_ts -le $end_ts ]]; do local date_str date_str=$(date -d "@$current_ts" +%Y-%m-%d) # Füge Datum nur einmal hinzu local found=0 for seen in "${seen_dates[@]}"; do [[ "$seen" == "$date_str" ]] && found=1 && break done if [[ $found -eq 0 ]]; then seen_dates+=("$date_str") local year month year=$(date -d "$date_str" +%Y) month=$(date -d "$date_str" +%m) logfiles+=("/var/log/easyname/$year/$month/$date_str.log") fi current_ts=$((current_ts + 86400)) # +1 Tag done elif [[ -n "$DAYS" ]]; then # Bestimme Datumsbereich für letzte N Tage for ((i=0; i/dev/null 2>&1; then echo "Warnung: 'dig' nicht gefunden - MX-Records werden übersprungen" >&2 return 0 fi echo "== MX für $DOMAIN ==" # MX Records local mx_records mx_records=$(dig +short MX "$DOMAIN" | sort -n) if [[ -z "$mx_records" ]]; then echo "Keine MX-Records gefunden" else echo "$mx_records" # A/AAAA Records für MX-Hosts echo "-- A/AAAA der MX:" while read -r priority mx_host; do [[ -n "$mx_host" ]] || continue mx_host="${mx_host%.}" # Trailing dot entfernen local a_records aaaa_records all_records a_records=$(dig +short A "$mx_host" | paste -sd',' -) aaaa_records=$(dig +short AAAA "$mx_host" | paste -sd',' -) all_records="" [[ -n "$a_records" ]] && all_records="$a_records" [[ -n "$aaaa_records" ]] && { [[ -n "$all_records" ]] && all_records="$all_records," all_records="$all_records$aaaa_records" } printf "%-40s | %s\n" "$mx_host" "${all_records:-}" done <<< "$mx_records" fi echo "========================================================================" } # Log Analysis - der Hauptalgorithmus analyze_logs() { local logfiles=() mapfile -t logfiles < <(determine_logfiles) if [[ $DRY_RUN -eq 1 ]]; then echo "=== DRY-RUN: Würde folgende Logfiles untersuchen ===" for logfile in "${logfiles[@]}"; do if [[ -f "$logfile" ]]; then echo " [EXISTS] $logfile" elif [[ -f "$logfile.gz" ]]; then echo " [EXISTS] $logfile.gz (komprimiert)" else echo " [MISSING] $logfile(.gz)" fi done return 0 fi # MX Records anzeigen falls gewünscht show_mx_records # Sammle alle relevanten Logzeilen local temp_file temp_file=$(mktemp) trap "rm -f '$temp_file'" EXIT for logfile in "${logfiles[@]}"; do local cat_cmd="" if [[ -f "$logfile" ]]; then cat_cmd="cat '$logfile'" elif [[ -f "$logfile.gz" ]]; then cat_cmd="zcat '$logfile.gz'" else continue fi # Zeitfilter für Stunden/Tage local time_filter="" if [[ -n "$HOURS" ]]; then local start_time start_time=$(date -d "-$HOURS hours" '+%b %_d %H:%M') time_filter="awk -v start='$start_time' '\$0 ~ start,0'" elif [[ -n "$DAYS" && $DAYS -lt 7 ]]; then # Nur für kleine Tageszahlen local start_time start_time=$(date -d "-$DAYS days" '+%b %_d') time_filter="awk -v start='$start_time' '\$0 ~ start,0'" fi # Baue Grep-Filter Pipeline local filter_pipeline="" # Basis-Filter: Envelope-From [[ -n "$FROM" ]] && filter_pipeline="$filter_pipeline | grep -F ' <= $FROM '" # TO-Filter: Empfänger-Regex [[ -n "$TO" ]] && filter_pipeline="$filter_pipeline | grep -E '( for <.*$TO.*>|\\*\\* .*$TO)'" # Domain-Filter: Ziel-Domain [[ -n "$DOMAIN" ]] && filter_pipeline="$filter_pipeline | grep -E '(@$DOMAIN>|to $DOMAIN|for $DOMAIN)'" # Message-ID Filter [[ -n "$MSGID" ]] && filter_pipeline="$filter_pipeline | grep -F '$MSGID'" # Zusätzlicher Regex [[ -n "$GREP" ]] && filter_pipeline="$filter_pipeline | grep -E '$GREP'" # Kommando zusammensetzen und ausführen local full_cmd="$cat_cmd" [[ -n "$time_filter" ]] && full_cmd="$full_cmd | $time_filter" full_cmd="$full_cmd$filter_pipeline" eval "$full_cmd" >> "$temp_file" 2>/dev/null || true done # Verarbeite gesammelte Logzeilen process_log_entries "$temp_file" } # Verarbeitung der Logeinträge und Gruppierung nach Message-ID process_log_entries() { local input_file="$1" [[ -s "$input_file" ]] || { echo "Keine passenden Logeinträge gefunden." return 0 } # AWK-Script für Gruppierung und Status-Klassifikation awk ' BEGIN { # Arrays für Message-ID Daten # msgid_data[id]["first"] = erste Zeitstempel # msgid_data[id]["last"] = letzte Zeitstempel # msgid_data[id]["from"] = Absender # msgid_data[id]["rcpts"] = Empfänger (getrennt durch ",") # msgid_data[id]["delivered"] = Anzahl erfolgreich zugestellt # msgid_data[id]["deferred"] = Anzahl wartend # msgid_data[id]["failed"] = Anzahl fehlgeschlagen # msgid_data[id]["logs"] = Alle Logzeilen (getrennt durch "\n") } # Extrahiere Message-ID aus jeder Zeile function extract_msgid(line) { # Exim Message-ID Format: 1uwRvz-000Fz2-CI match(line, /[0-9a-zA-Z]{6}-[0-9a-zA-Z]{6}-[0-9a-zA-Z]{2}/) if (RSTART > 0) { return substr(line, RSTART, RLENGTH) } return "" } # Extrahiere Zeitstempel (Format: 2025-09-11 08:32:15) function extract_timestamp(line) { match(line, /^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}/) if (RSTART > 0) { return substr(line, RSTART, RLENGTH) } return "" } # Hauptverarbeitung jeder Logzeile { msgid = extract_msgid($0) if (msgid == "") next timestamp = extract_timestamp($0) # Initialisiere Message-ID falls neu if (!(msgid in msgid_data)) { msgid_data[msgid]["first"] = timestamp msgid_data[msgid]["last"] = timestamp msgid_data[msgid]["from"] = "" msgid_data[msgid]["rcpts"] = "" msgid_data[msgid]["delivered"] = 0 msgid_data[msgid]["deferred"] = 0 msgid_data[msgid]["failed"] = 0 msgid_data[msgid]["logs"] = "" } # Aktualisiere Zeitstempel if (timestamp < msgid_data[msgid]["first"] || msgid_data[msgid]["first"] == "") { msgid_data[msgid]["first"] = timestamp } if (timestamp > msgid_data[msgid]["last"] || msgid_data[msgid]["last"] == "") { msgid_data[msgid]["last"] = timestamp } # Füge Logzeile hinzu if (msgid_data[msgid]["logs"] == "") { msgid_data[msgid]["logs"] = timestamp " | " $0 } else { msgid_data[msgid]["logs"] = msgid_data[msgid]["logs"] "\n" timestamp " | " $0 } # Analysiere Logzeile für Status und Details if ($0 ~ / <= /) { # Eingehende Mail: Extract Sender match($0, / <= ([^ ]+)/, arr) if (arr[1] != "") { msgid_data[msgid]["from"] = arr[1] } } else if ($0 ~ / => /) { # Erfolgreiche Zustellung msgid_data[msgid]["delivered"]++ # Extrahiere Empfänger match($0, / => ([^ ]+)/, arr) if (arr[1] != "" && index(msgid_data[msgid]["rcpts"], arr[1]) == 0) { if (msgid_data[msgid]["rcpts"] == "") { msgid_data[msgid]["rcpts"] = arr[1] } else { msgid_data[msgid]["rcpts"] = msgid_data[msgid]["rcpts"] "," arr[1] } } } else if ($0 ~ / == /) { # Verzögerte Zustellung (deferred) msgid_data[msgid]["deferred"]++ # Extrahiere Empfänger match($0, / == ([^ ]+)/, arr) if (arr[1] != "" && index(msgid_data[msgid]["rcpts"], arr[1]) == 0) { if (msgid_data[msgid]["rcpts"] == "") { msgid_data[msgid]["rcpts"] = arr[1] } else { msgid_data[msgid]["rcpts"] = msgid_data[msgid]["rcpts"] "," arr[1] } } } else if ($0 ~ / \*\* /) { # Fehlgeschlagene Zustellung msgid_data[msgid]["failed"]++ # Extrahiere Empfänger match($0, / \*\* ([^ ]+)/, arr) if (arr[1] != "" && index(msgid_data[msgid]["rcpts"], arr[1]) == 0) { if (msgid_data[msgid]["rcpts"] == "") { msgid_data[msgid]["rcpts"] = arr[1] } else { msgid_data[msgid]["rcpts"] = msgid_data[msgid]["rcpts"] "," arr[1] } } } } END { # Sortiere Message-IDs nach erstem Auftreten n = 0 for (msgid in msgid_data) { sorted_msgids[++n] = msgid } # Einfache Sortierung nach first timestamp for (i = 1; i <= n; i++) { for (j = i + 1; j <= n; j++) { if (msgid_data[sorted_msgids[i]]["first"] > msgid_data[sorted_msgids[j]]["first"]) { temp = sorted_msgids[i] sorted_msgids[i] = sorted_msgids[j] sorted_msgids[j] = temp } } } # Ausgabe aller gefundenen Message-IDs for (i = 1; i <= n; i++) { msgid = sorted_msgids[i] # Status-Klassifikation delivered = msgid_data[msgid]["delivered"] deferred = msgid_data[msgid]["deferred"] failed = msgid_data[msgid]["failed"] if (failed > 0 && delivered == 0 && deferred == 0) { status = "FAIL" } else if (delivered > 0 && failed == 0 && deferred == 0) { status = "OK" } else if (deferred > 0 && delivered == 0 && failed == 0) { status = "DELAY" } else if (delivered > 0 && (failed > 0 || deferred > 0)) { status = "PARTIAL" } else { status = "UNKNOWN" } # Kurze Übersicht printf "[%s] ID=%s | First=%s | From=%s | Rcpts=%s | Delivered=%d Deferred=%d Failed=%d\n", status, msgid, msgid_data[msgid]["first"], msgid_data[msgid]["from"], msgid_data[msgid]["rcpts"], delivered, deferred, failed # Detaillierte Ausgabe print "------------------------------------------------------------------------" printf "ID: %s\n", msgid printf "First: %s\n", msgid_data[msgid]["first"] printf "Last: %s\n", msgid_data[msgid]["last"] printf "From: %s\n", msgid_data[msgid]["from"] printf "Rcpts: %s\n", msgid_data[msgid]["rcpts"] printf "Status: %s (Delivered=%d, Deferred=%d, Failed=%d)\n", status, delivered, deferred, failed print "Log:" print msgid_data[msgid]["logs"] # Check für bekannte Delivery-Probleme if (msgid_data[msgid]["logs"] ~ /all hosts for.*have been failing/) { print " Note: DELIVERY STALL erkannt: '\''all hosts for domain failing (retry/backoff)'\''" } print "------------------------------------------------------------------------" print "" } if (n == 0) { print "Keine passenden Exim-Message-IDs gefunden." } } ' "$input_file" } # Main Execution main() { analyze_logs } # Script ausführen main "$@" # Built with the help of Claude Code