From 8e1e54139980ed8e6d014fb436fbef83d2e0f135 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 5 Oct 2026 15:55:09 +0200 Subject: [PATCH] first commit --- README.md | 38 ++++ kerio-queue-clean.sh | 462 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 500 insertions(+) create mode 100644 README.md create mode 100755 kerio-queue-clean.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d5d7fa --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# kerio-queue-clean.sh + +Entfernt Nachrichten aus der Kerio-Connect-Queue (`/opt/kerio/mailserver/store/queue`) +anhand von Filterregeln, z. B. wenn ein kompromittiertes Konto die Queue mit Spam flutet. + +- durchsucht die Queue rekursiv (inkl. Unterverzeichnisse) +- schaut in `.env` (Umschlag) und `.eml` (Header/Inhalt) +- entfernt immer alle Dateien `.*` einer Nachricht +- Kommandozeile **oder** ncurses-Oberfläche (`dialog` bzw. `whiptail`) + +## Schnellstart + +```bash +# ncurses-Oberfläche +sudo ./kerio-queue-clean.sh + +# erst nur ansehen +sudo ./kerio-queue-clean.sh -s hacked@firma.de -n + +# dann entfernen: Dienst kurz stoppen, Backup + Log +sudo ./kerio-queue-clean.sh -s hacked@firma.de -S -b /root/queue-backup -l /var/log/kerio-queue-clean.log +``` + +## Filter + +| Option | prüft | +|---|---| +| `-s` Absender | Umschlag-Absender / Auth-User aus `.env`, `From:`, `Return-Path:`, `Sender:` | +| `-r` Empfänger | Umschlag-Empfänger aus `.env`, `To:`, `Cc:`, `Delivered-To:` | +| `-j` Betreff | `Subject:` | +| `-c` Inhalt | komplette `.eml` (Header + Body) | + +Mehrere Muster des gleichen Typs sind ODER-verknüpft, verschiedene Typen UND-verknüpft. +Standard ist ein Teilstring-Vergleich, mit `-x` werden die Muster als Regex ausgewertet. Groß-/Kleinschreibung wird nie unterschieden. + +Mit `--inspect` siehst du den Aufbau einiger `.env`-Dateien, um die Filter zu prüfen. + +Alle Optionen zeigt `./kerio-queue-clean.sh --help`. diff --git a/kerio-queue-clean.sh b/kerio-queue-clean.sh new file mode 100755 index 0000000..e99bf63 --- /dev/null +++ b/kerio-queue-clean.sh @@ -0,0 +1,462 @@ +#!/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