#!/usr/bin/env bash # exitrace - Remote Exim-Log-Trace & Status für einzelne Mails/Adressen/Domain-Ziele # Maintainer: ljp # Führt Exim-Log-Analyse remote auf logger.in.nessus.at aus # ----------------------------------------------------------------------------- # Features: # - Remote-Ausführung auf logger.in.nessus.at (wo die Exim-Logs liegen) # - Sucht in /var/log/easyname/YYYY/MM/YYYY-MM-DD.log(.gz) auf logger # - Zeitfenster: letzte N Stunden/Tage ODER konkretes Datum/Spanne # - Filter: Absender (-f), Empfänger-Regex (-t), Ziel-Domain (-D), Message-ID (-m), Extra-Regex (-G) # - Gruppiert Treffer pro Exim-Message-ID und fasst Status zusammen # - Klassifikation: OK / PARTIAL / DELAY / FAIL / UNKNOWN # - Optional: MX-Übersicht für -D/--domain mittels --mx # # Beispiele: # exitrace -f sender@example.com -D example.de -H 48 # exitrace -m 1uwRvz-000Fz2-CI # exitrace -t '.*@example.de' -r 2025-09-09..2025-09-11 --mx # # Exitcodes: 0=OK (Treffer evtl. 0), 1=Fehlerhafte Parameter/Umgebung # ----------------------------------------------------------------------------- set -euo pipefail 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 <...>" -TO, --to REGEX Empfänger (Regex, matcht ' for ' oder in '** rcpt') -DOMAIN, --domain NAME Ziel-Domain (z. B. example.de) -MSGID, --msgid ID Exim-Message-ID (z. B. 1uwRvz-000Fz2-CI) -GREP, --grep REGEX Zusätzlicher Regex auf Logzeilen 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 Note: Wird automatisch remote auf logger.in.nessus.at ausgeführt. USAGE } # Validate functions (inline for remote execution) validate_positive_integer() { local value="$1" local param="$2" if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then echo "Fehler: $param benötigt positive Ganzzahl, erhalten: $value" >&2 exit 1 fi } validate_date() { local date_str="$1" local param="$2" if ! date -d "$date_str" >/dev/null 2>&1; then echo "Fehler: $param ungültiges Datum: $date_str" >&2 exit 1 fi if [[ ! "$date_str" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then echo "Fehler: $param Format muss YYYY-MM-DD sein, erhalten: $date_str" >&2 exit 1 fi } validate_date_range() { local range="$1" if [[ ! "$range" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})\.\.([0-9]{4}-[0-9]{2}-[0-9]{2})$ ]]; then echo "Fehler: --range Format muss YYYY-MM-DD..YYYY-MM-DD sein, erhalten: $range" >&2 exit 1 fi local start_date="${BASH_REMATCH[1]}" local end_date="${BASH_REMATCH[2]}" validate_date "$start_date" "--range start" validate_date "$end_date" "--range end" } # --------------------------- Argumente parsen -------------------------------- HOURS=""; DAYS=""; DATE_ONE=""; DATE_RANGE="" FROM=""; TO_REGEX=""; TARGET_DOMAIN=""; MSGID=""; GREPEXTRA="" DRYRUN=0; WANT_MX=0 # Alle Argumente für Remote-Weiterleitung sammeln REMOTE_ARGS=() while [[ $# -gt 0 ]]; do case "$1" in -H|--hours) HOURS="${2:-}" validate_positive_integer "$HOURS" "--hours" REMOTE_ARGS+=("$1" "$2") shift 2 ;; -DAYS|--days) DAYS="${2:-}" validate_positive_integer "$DAYS" "--days" REMOTE_ARGS+=("$1" "$2") shift 2 ;; -d|--date) DATE_ONE="${2:-}" validate_date "$DATE_ONE" "--date" REMOTE_ARGS+=("$1" "$2") shift 2 ;; -r|--range) DATE_RANGE="${2:-}" validate_date_range "$DATE_RANGE" REMOTE_ARGS+=("$1" "$2") shift 2 ;; -FROM|--from|-f) FROM="${2:-}" REMOTE_ARGS+=("$1" "$2") shift 2 ;; -TO|--to|-t) TO_REGEX="${2:-}" REMOTE_ARGS+=("$1" "$2") shift 2 ;; -DOMAIN|--domain|-D) TARGET_DOMAIN="${2:-}" REMOTE_ARGS+=("$1" "$2") shift 2 ;; -MSGID|--msgid|-m) MSGID="${2:-}" REMOTE_ARGS+=("$1" "$2") shift 2 ;; -GREP|--grep|-G) GREPEXTRA="${2:-}" REMOTE_ARGS+=("$1" "$2") shift 2 ;; --mx) WANT_MX=1 REMOTE_ARGS+=("$1") shift ;; -n|--dry-run) DRYRUN=1 REMOTE_ARGS+=("$1") shift ;; -h|--help) usage; exit 0 ;; *) echo "Unbekannte Option: $1" >&2 usage exit 1 ;; esac done # --- Remote-Kommando (wie checkms) --- LUSER="${LUSER:-}" if [[ -z "$LUSER" ]] && type go >/dev/null 2>&1; then REMOTE="go logger" elif [[ -n "$LUSER" ]]; then REMOTE="ssh ${LUSER}@logger.in.nessus.at" else echo "Fehler: LUSER Variable nicht gesetzt und 'go' Tool nicht verfügbar" >&2 echo "Setze LUSER oder installiere das 'go' Tool für automatische Logger-Verbindung" >&2 exit 1 fi # --- Remote Ausführung --- $REMOTE bash -s "${REMOTE_ARGS[@]}" <<'REMOTE_SCRIPT' #!/usr/bin/env bash # Remote exitrace execution on logger.in.nessus.at set -euo pipefail # Date/validation functions validate_positive_integer() { local value="$1" local param="$2" if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then echo "Fehler: $param benötigt positive Ganzzahl, erhalten: $value" >&2 exit 1 fi } validate_date() { local date_str="$1" local param="$2" if ! date -d "$date_str" >/dev/null 2>&1; then echo "Fehler: $param ungültiges Datum: $date_str" >&2 exit 1 fi if [[ ! "$date_str" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then echo "Fehler: $param Format muss YYYY-MM-DD sein, erhalten: $date_str" >&2 exit 1 fi } validate_date_range() { local range="$1" if [[ ! "$range" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})\.\.([0-9]{4}-[0-9]{2}-[0-9]{2})$ ]]; then echo "Fehler: --range Format muss YYYY-MM-DD..YYYY-MM-DD sein, erhalten: $range" >&2 exit 1 fi local start_date="${BASH_REMATCH[1]}" local end_date="${BASH_REMATCH[2]}" validate_date "$start_date" "--range start" validate_date "$end_date" "--range end" } # Date generation functions gen_dates_hours_back() { local hrs="$1" start_ts end_ts cur local DATE_FMT="%Y-%m-%d" 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" +%Y-%m-%d done } gen_dates_range() { local a="${1%%..*}" b="${1##*..}" cur="$a" while :; do echo "$cur" [[ "$cur" == "$b" ]] && break cur="$(date -d "$cur +1 day" +%Y-%m-%d)" done } files_for_date() { local d="$1" y="${d:0:4}" m="${d:5:2}" local p="/var/log/easyname/${y}/${m}/${d}.log" if [[ -f "$p" ]]; then printf "%s\n" "$p" fi if [[ -f "${p}.gz" ]]; then printf "%s\n" "${p}.gz" fi } # MX lookup function show_mx_records() { local domain="$1" if command -v dig >/dev/null 2>&1; then echo "== MX für ${domain} ==" dig +short MX "$domain" | sort -n || true echo "-- A/AAAA der MX:" while read -r mx; do local 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 "$domain" | sort -n) echo "========================================================================" else echo "(Hinweis) dig nicht gefunden, MX-Ausgabe übersprungen." fi } # Global settings LOGROOT="${LOGROOT:-/var/log/easyname}" TODAY="$(date +%Y-%m-%d)" # Parse remote arguments HOURS=""; DAYS=""; DATE_ONE=""; DATE_RANGE="" FROM=""; TO_REGEX=""; TARGET_DOMAIN=""; MSGID=""; GREPEXTRA="" 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 ;; -FROM|--from|-f) FROM="${2:-}"; shift 2 ;; -TO|--to|-t) TO_REGEX="${2:-}"; shift 2 ;; -DOMAIN|--domain|-D) TARGET_DOMAIN="${2:-}"; shift 2 ;; -MSGID|--msgid|-m) MSGID="${2:-}"; shift 2 ;; -GREP|--grep|-G) GREPEXTRA="${2:-}"; shift 2 ;; --mx) WANT_MX=1; shift ;; -n|--dry-run) DRYRUN=1; shift ;; *) shift ;; esac done # Generate date list declare -a DATES=() if [[ -n "$HOURS" ]]; then mapfile -t DATES < <(gen_dates_hours_back "$HOURS") elif [[ -n "$DAYS" ]]; then mapfile -t DATES < <(gen_dates_days_back "$DAYS") elif [[ -n "$DATE_ONE" ]]; then DATES=("$DATE_ONE") elif [[ -n "$DATE_RANGE" ]]; then mapfile -t DATES < <(gen_dates_range "$DATE_RANGE") else DATES=("$TODAY") fi # Collect files declare -a FILES=() for d in "${DATES[@]}"; do while read -r f; do FILES+=("$f"); done < <(files_for_date "$d" || true) done if (( DRYRUN )); then echo "Würde durchsuchen:" printf ' %s\n' "${FILES[@]}" exit 0 fi if [[ "${#FILES[@]}" -eq 0 ]]; then echo "Keine Logfiles gefunden unter ${LOGROOT}/YYYY/MM/DATE.log(.gz) fürs gewählte Zeitfenster." >&2 exit 0 fi # Build grep filters declare -a PREF_GREP=() [[ -n "$MSGID" ]] && PREF_GREP+=( "$MSGID" ) [[ -n "$FROM" ]] && PREF_GREP+=( "$FROM" ) [[ -n "$TARGET_DOMAIN" ]] && PREF_GREP+=( "$TARGET_DOMAIN" ) [[ -n "$GREPEXTRA" ]] && PREF_GREP+=( "$GREPEXTRA" ) # File reader cat_files() { local f for f in "${FILES[@]}"; do if [[ "$f" == *.gz ]]; then zcat -- "$f" else cat -- "$f" fi done } # Grep chain grep_chain() { if [[ "${#PREF_GREP[@]}" -eq 0 ]]; then cat_files else cat_files | grep -F -e "${PREF_GREP[0]}" $(for s in "${PREF_GREP[@]:1}"; do printf ' -e %q' "$s"; done) fi } # MX Block if (( WANT_MX )) && [[ -n "$TARGET_DOMAIN" ]]; then show_mx_records "$TARGET_DOMAIN" fi # AWK Analysis 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 (!(id in rcpts)) rcpts[id][""] = "" if (!(rc in rcpts[id])) rcpts[id][rc]=1 } 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] 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) 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); 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 is_success = 0 if (is_delivery && is_remote) { if (match($0, /C="[^"]*250[^"]*"/)) is_success=1 } # Deferred detection 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 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 } # Counters if (is_success) deliveries[msgid]++ if (is_defer) defers[msgid]++ if (is_perm_fail) fails[msgid]++ # Fail 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 { # Sort IDs 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 filter 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 logic 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" # Rcpts sammeln out_rcpts="" if (id in rcpts){ for (r in rcpts[id]) { if (r != "") { 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 if (fail_marker[id]) { printf " Note: DELIVERY STALL erkannt: '\''all hosts for domain failing (retry/backoff)'\''\n" } 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 } } ' REMOTE_SCRIPT # Built with the help of Claude Code