#!/usr/bin/env bash set -euo pipefail # ------------------------------------------------------------------- # dovecot-logreport.sh # # Zweck: # - Dovecot Logs parsen (imap-login/imap/pop3-login/lmtp/auth/...) # - Lesbare Key/Value-Ausgabe pro Event # - Filtermöglichkeiten: # * Ergebnis: --success / --fail / --error # * Zeit: --since / --until (ISO8601 empfohlen) # --last (bequem: "jetzt - Dauer", z.B. 10m/2h/7d) # * Inhalt: --addr REGEX (match gegen "user ip message") # --email STR (case-insensitive substring auf user) # * Typen: --only-login / --only-lmtp / --only-imap / --only-pop3 # --type REGEX (match gegen Dovecot type/service Token) # - Input: # * Standard-Log: /var/log/dovecot/dovecot.log # * --all: nimmt zusätzlich .0 und .*.gz # * --file: eigene Logdateien (mehrfach möglich) # Wichtig: Wenn kein absoluter Pfad angegeben ist, # wird automatisch /var/log/dovecot/ vorangestellt. # - Ausgabe: # * Default: Key/Value Block + Leerzeile als Trenner # * --oneline: 1 Zeile TSV (kein Leerzeilentrenner) # * --stats: Statistik statt Events (inkl. Matrix/Toplisten) # - Live-Modus: # * --follow: tail -F (gz-Dateien werden dafür übersprungen) # # Anforderungen/Annahmen: # - Timestamp steht als erstes Feld in ISO-Format (z.B. 2026-01-17T12:34:56+01:00) # => since/until/last funktionieren als Stringvergleich. # - Dovecot-Zeilen enthalten "dovecot:" # # Beispiele: # - Nur Login-Fails (Default logfile): # ./dovecot-logreport.sh --only-login --fail # - Stats für letzte 6 Stunden: # ./dovecot-logreport.sh --all --last 6h --stats # - Live follow nur IMAP domain: # ./dovecot-logreport.sh --follow --only-imap --email '@example.org' # ------------------------------------------------------------------- PROG="$(basename "$0")" # --- Standard-Logverzeichnis / Default-Datei --- LOGDIR="/var/log/dovecot" DEFAULT_LOG="${LOGDIR}/dovecot.log" # --- Optionen --- FOLLOW=0 ALL=0 ONELINE=0 STATS=0 # Service-/Event-Filter (OR-verknüpft, wenn mehrere gesetzt sind) ONLY_LOGIN=0 ONLY_LMTP=0 ONLY_IMAP=0 ONLY_POP3=0 # Regex-Filter für type/service TYPE_RE="" STATUS_FILTER="all" # all|success|fail|error SINCE="" UNTIL="" LAST_DUR="" ADDR_RE="" EMAIL_NEEDLE="" # --file kann mehrfach gesetzt werden declare -a FILES=() usage() { cat < INPUT OPTIONS --file PATH Add a log file to read (can be used multiple times). PATH may be: - Absolute: /somewhere/dovecot.log - Relative: dovecot.log.0 => becomes ${LOGDIR}/dovecot.log.0 Examples: --file dovecot.log --file dovecot.log.0 --file dovecot.log.1.gz --file /tmp/test.log --all Include rotated logs based on the default logfile: ${DEFAULT_LOG} ${DEFAULT_LOG}.0 ${DEFAULT_LOG}.*.gz Note: You can combine --all and --file to add extra files. --follow Follow the active logfile(s) (tail -F). .gz files cannot be followed and will be skipped in follow mode. TIME FILTERS (ISO8601 recommended) --since TS Only show entries with timestamp >= TS Example: --since 2026-01-17T00:00:00 --until TS Only show entries with timestamp <= TS Example: --until 2026-01-18T23:59:59 --last DUR Convenience: sets --since to "now - DUR" (only if --since is not set). DUR format: Nm (minutes) e.g. 10m Nh (hours) e.g. 2h Nd (days) e.g. 7d Examples: --last 30m --last 6h --last 1d CONTENT FILTERS --addr REGEX Regex match against combined "user ip message". Useful for filtering by IP, username, or keywords. Example: --addr 'rip=188\\.194\\.126\\.134|^188\\.194\\.126\\.134$' --addr 'imap-login' --email STR Case-insensitive substring match against extracted user. Works with full mail address, partial, or domain. Examples: --email 'sales@koma-elektronik.com' --email 'sales' --email '@koma-elektronik.com' TYPE / SERVICE FILTERS --type REGEX Regex match against the extracted Dovecot service token (type). Examples: --type 'imap-login' --type '^(imap|imap-login)$' --type 'lmtp|auth' --only-login Show only login-related events: - services ending in "-login" (imap-login, pop3-login) - typical login/auth messages (logged in, auth failed, login aborted, ...) --only-imap Show only IMAP related services (imap + imap-login) --only-pop3 Show only POP3 related services (pop3 + pop3-login) --only-lmtp Show only LMTP service RESULT FILTERS --success Only show classified success events --fail Only show classified fail events --error Only show classified error events OUTPUT OPTIONS --oneline One line per event (tab separated): TSTYPERESULTIPUSERMESSAGE --stats Print statistics instead of individual events: - By result (success/fail/error/info) - By type (Top 20) - Top IPs overall + fail-only - Top users overall + fail-only - Matrix: result@type (Top 30) - Fail combos: IP -> user (Top 20) HELP -h, --help Show this help EXAMPLES # Default logfile, show all failed login attempts $PROG --only-login --fail # All rotated logs, stats for brute-force overview (last 6 hours) $PROG --all --only-login --last 6h --stats # Follow current log, only IMAP, only fail, only a domain $PROG --follow --only-imap --fail --email '@example.org' # Use relative --file paths (auto-prefixed with ${LOGDIR}) $PROG --file dovecot.log --file dovecot.log.0 --file dovecot.log.1.gz --stats USAGE } die() { echo "ERROR: $*" >&2; exit 1; } # ------------------------------------------------------------------- # normalize_path(): # - If PATH starts with "/" -> keep it (absolute path) # - Otherwise -> prefix with LOGDIR # ------------------------------------------------------------------- normalize_path() { local p="$1" if [[ "$p" == /* ]]; then printf "%s" "$p" else printf "%s/%s" "$LOGDIR" "$p" fi } # ------------------------------------------------------------------- # Argumente parsen # ------------------------------------------------------------------- while [[ $# -gt 0 ]]; do case "$1" in --file) [[ $# -ge 2 ]] || die "--file needs PATH" FILES+=("$(normalize_path "$2")") shift 2 ;; --all) ALL=1; shift;; --follow) FOLLOW=1; shift;; --since) [[ $# -ge 2 ]] || die "--since needs TS"; SINCE="$2"; shift 2;; --until) [[ $# -ge 2 ]] || die "--until needs TS"; UNTIL="$2"; shift 2;; --last) [[ $# -ge 2 ]] || die "--last needs DUR (e.g. 10m, 2h, 7d)"; LAST_DUR="$2"; shift 2;; --addr) [[ $# -ge 2 ]] || die "--addr needs REGEX"; ADDR_RE="$2"; shift 2;; --email) [[ $# -ge 2 ]] || die "--email needs STR"; EMAIL_NEEDLE="$2"; shift 2;; --type) [[ $# -ge 2 ]] || die "--type needs REGEX"; TYPE_RE="$2"; shift 2;; --success) STATUS_FILTER="success"; shift;; --fail) STATUS_FILTER="fail"; shift;; --error) STATUS_FILTER="error"; shift;; --only-login) ONLY_LOGIN=1; shift;; --only-lmtp) ONLY_LMTP=1; shift;; --only-imap) ONLY_IMAP=1; shift;; --only-pop3) ONLY_POP3=1; shift;; --oneline) ONELINE=1; shift;; --stats) STATS=1; shift;; -h|--help) usage; exit 0;; *) die "Unknown option: $1";; esac done # ------------------------------------------------------------------- # --last DUR: # If --since is NOT set, compute SINCE as ISO-like timestamp using GNU date. # Accepts: Nm, Nh, Nd (minutes/hours/days) # Example: 10m, 2h, 7d # ------------------------------------------------------------------- if [[ -n "${LAST_DUR}" && -z "${SINCE}" ]]; then if [[ "${LAST_DUR}" =~ ^([0-9]+)([mhd])$ ]]; then n="${BASH_REMATCH[1]}" unit="${BASH_REMATCH[2]}" case "$unit" in m) SINCE="$(date -Is -d "${n} minutes ago")" ;; h) SINCE="$(date -Is -d "${n} hours ago")" ;; d) SINCE="$(date -Is -d "${n} days ago")" ;; esac else die "--last expects DUR like 10m, 2h, 7d (got: ${LAST_DUR})" fi fi # ------------------------------------------------------------------- # resolve_files(): # Legt fest, welche Dateien gelesen werden: # - explizite --file(s) # - plus optional --all Rotationslogs # - sonst default # ------------------------------------------------------------------- resolve_files() { local -a out=() # 1) explizit angegebene Dateien if [[ ${#FILES[@]} -gt 0 ]]; then out+=("${FILES[@]}") fi # 2) Rotationslogs (basierend auf DEFAULT_LOG) if [[ $ALL -eq 1 ]]; then local base="$DEFAULT_LOG" [[ -f "$base" ]] && out+=("$base") [[ -f "${base}.0" ]] && out+=("${base}.0") for gz in "${base}".*.gz; do [[ -f "$gz" ]] && out+=("$gz") done fi # 3) Fallback: Default if [[ ${#out[@]} -eq 0 ]]; then [[ -f "$DEFAULT_LOG" ]] || die "Default logfile $DEFAULT_LOG not found. Use --file or --all." out+=("$DEFAULT_LOG") fi # Duplikate entfernen (Reihenfolge bleibt erhalten) printf "%s\n" "${out[@]}" | awk '{ if(!seen[$0]++) print $0 }' } mapfile -t LOGFILES < <(resolve_files) # ------------------------------------------------------------------- # Follow: nur nicht-gz Dateien (tail -F kann gz nicht live verfolgen) # ------------------------------------------------------------------- if [[ $FOLLOW -eq 1 ]]; then mapfile -t FOLLOW_FILES < <(printf "%s\n" "${LOGFILES[@]}" | awk '!/\.gz$/') [[ ${#FOLLOW_FILES[@]} -gt 0 ]] || die "--follow requested, but only .gz files selected. Add an active log with --file." fi # ------------------------------------------------------------------- # POSIX-AWK Parser # - kompatibel zu mawk/nawk/busybox awk # ------------------------------------------------------------------- AWK_SCRIPT=' function tolow(s, i,c,r) { r="" for (i=1; i<=length(s); i++) { c=substr(s,i,1) r=r "" tolower(c) } return r } # classify(): # Versucht einen Dovecot-Logeintrag grob zu klassifizieren: # success / fail / error / info function classify(msg, m) { m=tolower(msg) # typische Fail-Muster (Auth/Login) if (m ~ /(auth failed|authentication failed|login aborted|password mismatch|unknown user|invalid password|disconnected: auth failed)/) return "fail" # typische Error-Muster if (m ~ /(fatal|panic|error|critical|internal error|temporary failure)/) return "error" # typische Success-Muster if (m ~ /(logged in:|stored mail into mailbox|sieve:.*stored mail|connect from)/) return "success" if (m ~ /(disconnected: logged out|disconnect .*logged out)/) return "success" return "info" } # extract_type(): # Extracts the dovecot service token after "dovecot: ". # Examples: # "dovecot: imap-login: ..." -> imap-login # "dovecot: imap(user@dom):" -> imap # "dovecot: lmtp(123):" -> lmtp function extract_type(line, arr, t) { t="-" if (match(line, /dovecot: [^: ]+/, arr)) { t=substr(arr[0], length("dovecot: ")+1) sub(/\(.*/, "", t) } return t } # extract_user(): # Tries to extract a user/mailbox from common patterns. function extract_user(line, arr, u) { if (match(line, /user=<[^>]+>/, arr)) { return substr(arr[0], 7, length(arr[0])-7-1) } if (match(line, /: (imap|pop3|lmtp)\([^)]*\)/, arr)) { u=arr[0] sub(/^: (imap|pop3|lmtp)\(/, "", u) sub(/\)$/, "", u) return u } if (match(line, /: lmtp\([^)]*\) alles ok. # Wenn mind. einer gesetzt -> OR-Verknüpfung: # - only_lmtp: type == lmtp # - only_imap: imap oder imap-login # - only_pop3: pop3 oder pop3-login # - only_login: is_login_event() true function service_match(type, msg, only_login, only_lmtp, only_imap, only_pop3) { if (only_login==0 && only_lmtp==0 && only_imap==0 && only_pop3==0) return 1 if (only_lmtp==1 && type=="lmtp") return 1 if (only_imap==1 && (type=="imap" || type=="imap-login")) return 1 if (only_pop3==1 && (type=="pop3" || type=="pop3-login")) return 1 if (only_login==1 && is_login_event(type, msg)==1) return 1 return 0 } # type_regex_match(): # Optionaler --type Filter (regex gegen type token) function type_regex_match(type, typere) { if (typere == "") return 1 if (type ~ typere) return 1 return 0 } # ---- STATS helper: sort and print top N ---- function print_top_sorted(title, arr, n, k,i,j,keys,tmp) { print title i=0 for (k in arr) { i++; keys[i]=k } for (i=1; i<=length(keys); i++) { for (j=i+1; j<=length(keys); j++) { if (arr[keys[j]] > arr[keys[i]]) { tmp=keys[i]; keys[i]=keys[j]; keys[j]=tmp } } } for (i=1; i<=length(keys) && i<=n; i++) { printf " %7d %s\n", arr[keys[i]], keys[i] } print "" } # ---- STATS helper: sort and print top N for matrix-like keys ---- function print_matrix(title, arr, n, k,i,j,keys,tmp) { print title i=0 for (k in arr) { i++; keys[i]=k } for (i=1; i<=length(keys); i++) { for (j=i+1; j<=length(keys); j++) { if (arr[keys[j]] > arr[keys[i]]) { tmp=keys[i]; keys[i]=keys[j]; keys[j]=tmp } } } for (i=1; i<=length(keys) && i<=n; i++) { printf " %7d %s\n", arr[keys[i]], keys[i] } print "" } BEGIN { since = ENVIRON["SINCE"] until = ENVIRON["UNTIL"] addrre = ENVIRON["ADDR_RE"] want = ENVIRON["STATUS_FILTER"] oneline = ENVIRON["ONELINE"] + 0 stats = ENVIRON["STATS"] + 0 emailneedle = tolower(ENVIRON["EMAIL_NEEDLE"]) onlylogin = ENVIRON["ONLY_LOGIN"] + 0 onlylmtp = ENVIRON["ONLY_LMTP"] + 0 onlyimap = ENVIRON["ONLY_IMAP"] + 0 onlypop3 = ENVIRON["ONLY_POP3"] + 0 typere = ENVIRON["TYPE_RE"] total=0 } { line=$0 if (line !~ /dovecot:/) next # Timestamp (ISO) steht am Anfang ts=$1 if (since != "" && ts < since) next if (until != "" && ts > until) next type = extract_type(line) # --type Filter if (type_regex_match(type, typere) == 0) next # Message: alles nach "dovecot: " msg=line sub(/^.*dovecot: /, "", msg) # only_* Filter if (service_match(type, msg, onlylogin, onlylmtp, onlyimap, onlypop3) == 0) next user = extract_user(line) ip = extract_ip(line) res = classify(msg) # Ergebnisfilter if (want == "success" && res != "success") next if (want == "fail" && res != "fail") next if (want == "error" && res != "error") next # addr filter: regex gegen "user ip msg" hay = user " " ip " " msg if (addrre != "" && hay !~ addrre) next # email filter: substring nur gegen user if (emailneedle != "" && index(tolower(user), emailneedle) == 0) next total++ # Stats sammeln if (stats == 1) { c_res[res]++ c_type[type]++ c_res_type[res "@" type]++ if (ip != "-") { c_ip[ip]++ if (res == "fail") c_ip_fail[ip]++ } if (user != "-") { c_user[user]++ if (res == "fail") c_user_fail[user]++ } if (res == "fail" && ip != "-" && user != "-") { c_fail_ip_user[ip " -> " user]++ } next } # Eventausgabe if (oneline == 1) { printf "%s\t%s\t%s\t%s\t%s\t%s\n", ts, type, res, ip, user, msg next } print "time: " ts print "type: " type print "client-ip: " ip print "user: " user print "result: " res print "message: " msg print "" } END { if (stats != 1) exit print "Events: " total print "" print "By result" printf " %7d success\n", c_res["success"]+0 printf " %7d fail\n", c_res["fail"]+0 printf " %7d error\n", c_res["error"]+0 printf " %7d info\n", c_res["info"]+0 print "" print_top_sorted("By type (Top 20)", c_type, 20) print_top_sorted("Top client IPs (overall, Top 10)", c_ip, 10) print_top_sorted("Top client IPs (fail only, Top 10)", c_ip_fail, 10) print_top_sorted("Top users (overall, Top 10)", c_user, 10) print_top_sorted("Top users (fail only, Top 10)", c_user_fail, 10) print_matrix("Matrix result@type (Top 30)", c_res_type, 30) print_top_sorted("Fail combos IP -> user (Top 20)", c_fail_ip_user, 20) } ' # ------------------------------------------------------------------- # run_pipeline(): # Liest die ausgewählten Files (inkl. .gz über zcat -f) und pipe't # in awk. In --follow Mode: tail -F auf nicht-gz Dateien. # ------------------------------------------------------------------- run_pipeline() { local f if [[ $FOLLOW -eq 1 ]]; then tail -F "${FOLLOW_FILES[@]}" | \ env SINCE="$SINCE" UNTIL="$UNTIL" ADDR_RE="$ADDR_RE" EMAIL_NEEDLE="$EMAIL_NEEDLE" TYPE_RE="$TYPE_RE" \ ONLY_LOGIN="$ONLY_LOGIN" ONLY_LMTP="$ONLY_LMTP" ONLY_IMAP="$ONLY_IMAP" ONLY_POP3="$ONLY_POP3" \ STATUS_FILTER="$STATUS_FILTER" ONELINE="$ONELINE" STATS="$STATS" \ awk "$AWK_SCRIPT" return fi { for f in "${LOGFILES[@]}"; do if [[ "$f" =~ \.gz$ ]]; then zcat -f -- "$f" 2>/dev/null || true else cat -- "$f" 2>/dev/null || true fi done } | env SINCE="$SINCE" UNTIL="$UNTIL" ADDR_RE="$ADDR_RE" EMAIL_NEEDLE="$EMAIL_NEEDLE" TYPE_RE="$TYPE_RE" \ ONLY_LOGIN="$ONLY_LOGIN" ONLY_LMTP="$ONLY_LMTP" ONLY_IMAP="$ONLY_IMAP" ONLY_POP3="$ONLY_POP3" \ STATUS_FILTER="$STATUS_FILTER" ONELINE="$ONELINE" STATS="$STATS" \ awk "$AWK_SCRIPT" } run_pipeline