Files
postfix/dovecot-logreport.sh

668 lines
18 KiB
Bash
Executable File

#!/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 <<USAGE
$PROG - Dovecot Log Analyzer
SYNOPSIS
$PROG [options]
DEFAULTS
- Default logfile: ${DEFAULT_LOG}
- Default logdir : ${LOGDIR}
- If you pass --file with a RELATIVE path (no leading "/"),
it will be interpreted as: ${LOGDIR}/<yourfile>
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):
TS<TAB>TYPE<TAB>RESULT<TAB>IP<TAB>USER<TAB>MESSAGE
--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\([^)]*\)</, arr)) {
u=arr[0]
sub(/^: lmtp\(/, "", u)
sub(/<$/, "", u)
return u
}
if (match(line, /: auth\([^,]+,/, arr)) {
u=arr[0]
sub(/^: auth\(/, "", u)
sub(/,$/, "", u)
return u
}
return "-"
}
# extract_ip():
# Extracts remote IP from patterns like "rip=1.2.3.4"
# or auth(user,IP,sasl:...)
function extract_ip(line, arr, s) {
if (match(line, /rip=[^ ,]+/, arr)) {
return substr(arr[0], 5)
}
if (match(line, /: auth\([^,]+,[^,]+,/, arr)) {
s=arr[0]
sub(/^: auth\([^,]+,/, "", s)
sub(/,.*/, "", s)
return s
}
return "-"
}
# is_login_event():
# Heuristik: identifiziert Login/Auth Events
function is_login_event(type, msg, m) {
if (type ~ /-login$/) return 1
m=tolower(msg)
if (m ~ /(logged in:|auth failed|authentication failed|login aborted|invalid password|unknown user)/) return 1
return 0
}
# service_match():
# only_* Filter: Wenn keiner gesetzt -> 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