diff --git a/dovecot-logreport.sh b/dovecot-logreport.sh new file mode 100755 index 0000000..30ff640 --- /dev/null +++ b/dovecot-logreport.sh @@ -0,0 +1,667 @@ +#!/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 +