#!/usr/bin/env bash # # kerio-queue-clean.sh # # Entfernt Nachrichten aus der Kerio-Connect-Queue anhand von Filterregeln # (Absender, Empfaenger, Betreff, Inhalt). Gedacht fuer den Fall, dass ein # kompromittiertes Konto die Queue mit Spam flutet. # # Jede Nachricht besteht aus .eml (Mail) und .env (Umschlag). # Die Queue darf Unterverzeichnisse haben; es wird rekursiv gesucht. # # Aufruf ohne Parameter (oder mit -i) startet die ncurses-Oberflaeche # (dialog oder whiptail). Siehe --help. set -o pipefail VERSION="1.0" QUEUE_DIR="/opt/kerio/mailserver/store/queue" SERVICE_NAME="kerio-connect" BACKUP_DIR="" LOG_FILE="" USE_REGEX=0 DRY_RUN=0 ASSUME_YES=0 STOP_SERVICE=0 TUI=0 INSPECT=0 SENDERS=() RCPTS=() SUBJECTS=() CONTENTS=() TMPDIR_KQ=$(mktemp -d "${TMPDIR:-/tmp}/kerio-queue-clean.XXXXXX") || exit 1 trap 'rm -rf "$TMPDIR_KQ"' EXIT RESULT_FILE="$TMPDIR_KQ/result.tsv" # --------------------------------------------------------------------------- # Matcher: liest .env (optional) und .eml und gibt bei Treffer # eine Zeile "Umschlag-Absender From-Header Betreff" aus. # Muster kommen ueber ENVIRON (keine Escape-Verarbeitung wie bei -v). # - mehrere Muster desselben Typs -> ODER # - verschiedene Typen kombiniert -> UND # --------------------------------------------------------------------------- read -r -d '' AWK_MATCH <<'AWK' function hit(s, pats, n, a, i, p) { n = split(pats, a, "\n") s = tolower(s) for (i = 1; i <= n; i++) { if (a[i] == "") continue p = tolower(a[i]) if (rx) { if (s ~ p) return 1 } else if (index(s, p)) return 1 } return 0 } function hdr_done( name, val) { if (cur == "") return name = tolower(cur); sub(/:.*/, "", name) val = cur; sub(/^[^:]*:[ \t]*/, "", val) if (name == "from" || name == "return-path" || name == "sender") { hsend = hsend "\n" val if (name == "from" && hfrom == "") hfrom = val } else if (name == "to" || name == "cc" || name == "delivered-to" || name == "x-original-to") { hrcpt = hrcpt "\n" val } else if (name == "subject" && subj == "") { subj = val } cur = "" } function clean(s) { gsub(/[\t\r\n]+/, " ", s); sub(/^ +/, "", s); return s } BEGIN { sp = ENVIRON["KQ_SENDERS"]; rp = ENVIRON["KQ_RCPTS"] jp = ENVIRON["KQ_SUBJECTS"]; cp = ENVIRON["KQ_CONTENTS"] rx = ENVIRON["KQ_REGEX"] + 0; envf = ENVIRON["KQ_ENVF"] } FILENAME == envf { sub(/\r$/, "") if ($0 != "") envl[++en] = $0 next } { sub(/\r$/, "") if (!inbody) { if ($0 == "") { hdr_done(); inbody = 1; if (cp == "" || chit) exit; next } if ($0 ~ /^[ \t]/) { sub(/^[ \t]+/, " "); cur = cur $0 } else { hdr_done(); cur = $0 } if (cp != "" && !chit && hit($0, cp)) chit = 1 next } if (cp != "" && hit($0, cp)) { chit = 1; exit } } END { hdr_done() # Umschlag auswerten: Zeilen mit Schluesselwort vor der Adresse zuordnen, # sonst gilt: erste Adresszeile = Absender, weitere = Empfaenger. kw = 0 for (i = 1; i <= en; i++) { pre = tolower(envl[i]); sub(/[<@].*/, "", pre) if (pre ~ /from|sender|return|auth|user/) { esend = esend "\n" envl[i]; if (efirst == "") efirst = envl[i]; kw = 1 } else if (pre ~ /rcpt|recip|to/) { ercpt = ercpt "\n" envl[i]; kw = 1 } } if (!kw) { for (i = 1; i <= en; i++) { if (envl[i] !~ /@|<>/) continue if (efirst == "") { efirst = envl[i]; esend = envl[i] } else ercpt = ercpt "\n" envl[i] } } ok = 1 if (sp != "" && !hit(esend "\n" hsend, sp)) ok = 0 if (rp != "" && !hit(ercpt "\n" hrcpt, rp)) ok = 0 if (jp != "" && !hit(subj, jp)) ok = 0 if (cp != "" && !chit) ok = 0 if (ok) printf "%s\t%s\t%s\n", clean(efirst), clean(hfrom), clean(subj) } AWK # --------------------------------------------------------------------------- # Hilfsfunktionen # --------------------------------------------------------------------------- die() { echo "FEHLER: $*" >&2; exit 1; } info() { echo "$*" >&2; } usage() { cat < 0 )) } check_queue() { [[ -d $QUEUE_DIR ]] || { echo "Queue-Verzeichnis '$QUEUE_DIR' existiert nicht."; return 1; } [[ -r $QUEUE_DIR ]] || { echo "Keine Leserechte auf '$QUEUE_DIR' (als root starten?)."; return 1; } return 0 } # scan_queue MODE (MODE: quiet | stderr | gauge) # Schreibt Treffer nach $RESULT_FILE: basisumschlagfrombetreff scan_queue() { local mode=$1 total i=0 eml base res last=-1 pct : > "$RESULT_FILE" export_patterns total=$(find "$QUEUE_DIR" -type f -name '*.eml' 2>/dev/null | wc -l) (( total == 0 )) && { [[ $mode == gauge ]] && echo 100; return 0; } while IFS= read -r -d '' eml; do i=$((i + 1)) base=${eml%.eml} if [[ -f $base.env ]]; then res=$(KQ_ENVF="$base.env" awk "$AWK_MATCH" "$base.env" "$eml" 2>/dev/null) else res=$(KQ_ENVF="" awk "$AWK_MATCH" "$eml" 2>/dev/null) fi [[ -n $res ]] && printf '%s\t%s\n' "$base" "$res" >> "$RESULT_FILE" pct=$(( i * 100 / total )) if (( pct != last )); then last=$pct case $mode in gauge) echo "$pct" ;; stderr) printf '\rDurchsuche Queue: %3d%% (%d/%d)' "$pct" "$i" "$total" >&2 ;; esac fi done < <(find "$QUEUE_DIR" -type f -name '*.eml' -print0 2>/dev/null) [[ $mode == stderr ]] && echo >&2 return 0 } service_ctl() { # service_ctl stop|start if command -v systemctl >/dev/null 2>&1; then systemctl "$1" "$SERVICE_NAME" elif [[ -x /etc/init.d/$SERVICE_NAME ]]; then "/etc/init.d/$SERVICE_NAME" "$1" else echo "Dienst '$SERVICE_NAME' kann nicht gesteuert werden." >&2 return 1 fi } # delete_matches LISTE MODE (MODE: quiet | stderr | gauge) # Loescht (oder verschiebt) alle Dateien .* der Eintraege in LISTE. delete_matches() { local list=$1 mode=$2 total i=0 base env from subj rel f done_cnt=0 err_cnt=0 total=$(wc -l < "$list") (( total == 0 )) && return 0 while IFS=$'\t' read -r base env from subj; do i=$((i + 1)) local files=() for f in "$base".*; do [[ -e $f ]] && files+=("$f"); done if (( ${#files[@]} > 0 )); then if [[ -n $BACKUP_DIR ]]; then rel=${base#"$QUEUE_DIR"/} mkdir -p "$BACKUP_DIR/$(dirname "$rel")" && mv -f -- "${files[@]}" "$BACKUP_DIR/$(dirname "$rel")/" else rm -f -- "${files[@]}" fi if (( $? == 0 )); then done_cnt=$((done_cnt + 1)) [[ -n $LOG_FILE ]] && printf '%s\t%s\t%s\t%s\t%s\n' \ "$(date '+%F %T')" "$base" "$env" "$from" "$subj" >> "$LOG_FILE" else err_cnt=$((err_cnt + 1)) fi fi case $mode in gauge) echo $(( i * 100 / total )) ;; stderr) (( i % 100 == 0 || i == total )) && printf '\rEntferne: %d/%d' "$i" "$total" >&2 ;; esac done < "$list" [[ $mode == stderr ]] && echo >&2 echo "$done_cnt $err_cnt" > "$TMPDIR_KQ/delete.stat" } delete_with_service() { # delete_with_service LISTE MODE local stopped=0 if (( STOP_SERVICE )); then if service_ctl stop >/dev/null 2>&1; then stopped=1 else echo "Warnung: Dienst konnte nicht gestoppt werden." >&2; fi fi delete_matches "$1" "$2" (( stopped )) && service_ctl start >/dev/null 2>&1 return 0 } inspect_queue() { local n=${1:-3} env eml check_queue || exit 1 while IFS= read -r -d '' env; do eml=${env%.env}.eml echo "===== $env =====" cat -- "$env" if [[ -f $eml ]]; then echo "----- Header aus $(basename "$eml") -----" awk '/^\r?$/ {exit} /^(From|To|Cc|Subject|Return-Path|Sender):/' "$eml" fi echo n=$((n - 1)); (( n <= 0 )) && break done < <(find "$QUEUE_DIR" -type f -name '*.env' -print0) } print_results() { local base env from subj printf '%-40s %-30s %s\n' "DATEI" "ABSENDER" "BETREFF" while IFS=$'\t' read -r base env from subj; do printf '%-40s %-30s %s\n' "${base#"$QUEUE_DIR"/}" "${env:-$from}" "$subj" done < "$RESULT_FILE" } # --------------------------------------------------------------------------- # Kommandozeilenmodus # --------------------------------------------------------------------------- run_cli() { check_queue >&2 || exit 1 have_filter || die "Mindestens ein Filter (-s/-r/-j/-c) ist noetig. Siehe --help." scan_queue stderr local count count=$(wc -l < "$RESULT_FILE") print_results echo echo "Treffer: $count" (( count == 0 )) && exit 0 (( DRY_RUN )) && { echo "Trockenlauf - nichts geloescht."; exit 0; } if (( ! ASSUME_YES )); then local action="LOESCHEN" [[ -n $BACKUP_DIR ]] && action="nach $BACKUP_DIR VERSCHIEBEN" read -r -p "$count Nachrichten $action? Zum Bestaetigen 'ja' eingeben: " ans [[ $ans == ja ]] || { echo "Abgebrochen."; exit 0; } fi delete_with_service "$RESULT_FILE" stderr local ok err read -r ok err < "$TMPDIR_KQ/delete.stat" echo "Erledigt: $ok entfernt, $err Fehler." } # --------------------------------------------------------------------------- # ncurses-Oberflaeche (dialog oder whiptail) # --------------------------------------------------------------------------- DLG="" ui() { "$DLG" --backtitle "Kerio Connect Queue Cleaner $VERSION" "$@" 3>&1 1>&2 2>&3; } join_list() { local IFS=';'; echo "$*"; } split_list() { # split_list ARRAYNAME "a;b;c" local -n _arr=$1 local item _arr=() IFS=';' read -r -a _tmp <<< "$2" for item in "${_tmp[@]}"; do item=$(sed 's/^[[:space:]]*//; s/[[:space:]]*$//' <<< "$item") [[ -n $item ]] && _arr+=("$item") done } edit_list() { # edit_list ARRAYNAME TITEL local -n _ref=$1 local val val=$(ui --title "$2" --inputbox \ "Mehrere Muster mit ';' trennen (ODER-Verknuepfung).\nLeer lassen = Filter aus." \ 10 70 "$(join_list "${_ref[@]}")") || return split_list "$1" "$val" } yn() { (( $1 )) && echo "an" || echo "aus"; } tui_search_and_delete() { local msg rows cols count if ! msg=$(check_queue); then ui --msgbox "$msg" 8 70; return; fi if ! have_filter; then ui --msgbox "Bitte zuerst mindestens einen Filter setzen." 8 60; return; fi scan_queue gauge | ui --title "Suche" --gauge "Durchsuche $QUEUE_DIR ..." 8 70 0 count=$(wc -l < "$RESULT_FILE") if (( count == 0 )); then ui --msgbox "Keine passenden Nachrichten gefunden." 8 50; return; fi rows=$(tput lines 2>/dev/null || echo 24); cols=$(tput cols 2>/dev/null || echo 80) local selected="$TMPDIR_KQ/selected.tsv" if (( count <= 2000 )); then local args=() i=0 base env from subj w=$((cols - 20)) while IFS=$'\t' read -r base env from subj; do i=$((i + 1)) local text="${env:-$from} | $subj" args+=("$i" " ${text:0:$w}" on) done < "$RESULT_FILE" local choice choice=$(ui --title "$count Treffer" --separate-output --checklist \ "Zu entfernende Nachrichten (Leertaste = abwaehlen):" \ $((rows - 4)) $((cols - 6)) $((rows - 12)) "${args[@]}") || return [[ -z $choice ]] && return awk 'NR==FNR {sel[$1]; next} (FNR in sel)' <(echo "$choice") "$RESULT_FILE" > "$selected" else print_results > "$TMPDIR_KQ/list.txt" ui --title "$count Treffer" --scrolltext --textbox "$TMPDIR_KQ/list.txt" $((rows - 4)) $((cols - 6)) cp "$RESULT_FILE" "$selected" fi count=$(wc -l < "$selected") local action="LOESCHEN" [[ -n $BACKUP_DIR ]] && action="nach $BACKUP_DIR VERSCHIEBEN" local svc="" (( STOP_SERVICE )) && svc="\n\nDer Dienst '$SERVICE_NAME' wird dafuer kurz gestoppt." ui --title "Bestaetigung" --defaultno --yesno "$count Nachrichten wirklich $action?$svc" 10 70 || return delete_with_service "$selected" gauge | ui --title "Entferne" --gauge "Entferne Nachrichten ..." 8 70 0 local ok err read -r ok err < "$TMPDIR_KQ/delete.stat" ui --msgbox "Fertig.\n\nEntfernt: $ok\nFehler: $err" 10 50 } run_tui() { if command -v dialog >/dev/null 2>&1; then DLG=dialog elif command -v whiptail >/dev/null 2>&1; then DLG=whiptail else die "Weder 'dialog' noch 'whiptail' installiert (apt install dialog). Alternativ Parameter nutzen, siehe --help." fi local choice val while true; do choice=$(ui --title "Hauptmenue" --cancel-button "Beenden" --menu \ "Filter setzen und dann 'Suchen' waehlen.\nGleicher Typ = ODER, verschiedene Typen = UND." 22 78 12 \ 1 "Queue-Pfad : $QUEUE_DIR" \ 2 "Absender : $(join_list "${SENDERS[@]}")" \ 3 "Empfaenger : $(join_list "${RCPTS[@]}")" \ 4 "Betreff : $(join_list "${SUBJECTS[@]}")" \ 5 "Inhalt (.eml) : $(join_list "${CONTENTS[@]}")" \ 6 "Regex-Modus : $(yn $USE_REGEX)" \ 7 "Backup statt rm : ${BACKUP_DIR:-aus}" \ 8 "Dienst stoppen : $(yn $STOP_SERVICE) ($SERVICE_NAME)" \ 9 "Protokoll : ${LOG_FILE:-aus}" \ S ">> Suchen und entfernen" \ I " Beispiel .env/Header ansehen" \ Q " Beenden") || break case $choice in 1) val=$(ui --inputbox "Queue-Verzeichnis:" 8 70 "$QUEUE_DIR") && QUEUE_DIR=${val%/} ;; 2) edit_list SENDERS "Absender" ;; 3) edit_list RCPTS "Empfaenger" ;; 4) edit_list SUBJECTS "Betreff" ;; 5) edit_list CONTENTS "Inhalt" ;; 6) USE_REGEX=$(( !USE_REGEX )) ;; 7) val=$(ui --inputbox "Backup-Verzeichnis (leer = endgueltig loeschen):" 8 70 "$BACKUP_DIR") && BACKUP_DIR=${val%/} ;; 8) STOP_SERVICE=$(( !STOP_SERVICE )) ;; 9) val=$(ui --inputbox "Protokolldatei (leer = aus):" 8 70 "${LOG_FILE:-/var/log/kerio-queue-clean.log}") && LOG_FILE=$val ;; S) tui_search_and_delete ;; I) inspect_queue 3 > "$TMPDIR_KQ/inspect.txt" 2>&1 ui --title "Beispiele" --scrolltext --textbox "$TMPDIR_KQ/inspect.txt" 22 90 ;; Q) break ;; esac done clear } # --------------------------------------------------------------------------- # Parameter # --------------------------------------------------------------------------- [[ $# -eq 0 ]] && TUI=1 while [[ $# -gt 0 ]]; do case $1 in -s|--sender) SENDERS+=("${2:?$1 braucht ein Argument}"); shift ;; -r|--recipient) RCPTS+=("${2:?$1 braucht ein Argument}"); shift ;; -j|--subject) SUBJECTS+=("${2:?$1 braucht ein Argument}"); shift ;; -c|--content) CONTENTS+=("${2:?$1 braucht ein Argument}"); shift ;; -x|--regex) USE_REGEX=1 ;; -n|--dry-run) DRY_RUN=1 ;; -y|--yes) ASSUME_YES=1 ;; -b|--backup) BACKUP_DIR="${2:?$1 braucht ein Argument}"; BACKUP_DIR=${BACKUP_DIR%/}; shift ;; -S|--stop-service) STOP_SERVICE=1 ;; --service) SERVICE_NAME="${2:?$1 braucht ein Argument}"; shift ;; -l|--log) LOG_FILE="${2:?$1 braucht ein Argument}"; shift ;; -d|--queue) QUEUE_DIR="${2:?$1 braucht ein Argument}"; QUEUE_DIR=${QUEUE_DIR%/}; shift ;; -i|--tui) TUI=1 ;; --inspect) INSPECT=1; [[ ${2:-} =~ ^[0-9]+$ ]] && { INSPECT=$2; shift; } ;; -h|--help) usage; exit 0 ;; *) usage >&2; die "Unbekannte Option: $1" ;; esac shift done if (( INSPECT )); then inspect_queue "$INSPECT"; exit 0; fi if (( TUI )); then run_tui; else run_cli; fi