first commit

This commit is contained in:
duffyduck
2026-10-05 15:55:09 +02:00
commit 8e1e541399
2 changed files with 500 additions and 0 deletions
+38
View File
@@ -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 `<name>.env` (Umschlag) und `<name>.eml` (Header/Inhalt)
- entfernt immer alle Dateien `<name>.*` 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`.
+462
View File
@@ -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 <name>.eml (Mail) und <name>.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 <name>.env (optional) und <name>.eml und gibt bei Treffer
# eine Zeile "Umschlag-Absender <TAB> From-Header <TAB> 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 <<EOF
kerio-queue-clean.sh $VERSION - Mails nach Filterregeln aus der Kerio-Connect-Queue entfernen
Aufruf:
$0 ncurses-Oberflaeche starten
$0 [Optionen] Kommandozeilenmodus
Filter (mehrfach angebbar; gleicher Typ = ODER, verschiedene Typen = UND):
-s, --sender MUSTER Absender (Umschlag/.env, From:, Return-Path:, Sender:)
-r, --recipient MUSTER Empfaenger (Umschlag/.env, To:, Cc:, Delivered-To:)
-j, --subject MUSTER Betreff
-c, --content MUSTER beliebiger Text in der .eml (Header + Body)
-x, --regex Muster als erweiterte Regex statt Teilstring
(Gross-/Kleinschreibung wird immer ignoriert)
Aktionen:
-n, --dry-run nur auflisten, nichts loeschen
-y, --yes ohne Rueckfrage loeschen
-b, --backup DIR Treffer nach DIR verschieben statt loeschen
-S, --stop-service Kerio-Dienst waehrend des Loeschens stoppen (empfohlen)
--service NAME Name des Dienstes (Standard: $SERVICE_NAME)
-l, --log DATEI geloeschte Mails protokollieren
Sonstiges:
-d, --queue DIR Queue-Verzeichnis (Standard: $QUEUE_DIR)
-i, --tui ncurses-Oberflaeche erzwingen
--inspect [N] N Beispiel-.env-Dateien + Header zeigen (Standard 3)
-h, --help diese Hilfe
Beispiele:
$0 -s hacked@firma.de -n
$0 -s hacked@firma.de -S -b /root/queue-backup -l /var/log/kerio-queue-clean.log
$0 -x -s '^.*@firma\.de' -j 'invoice|payment' -y
EOF
}
export_patterns() {
local IFS=$'\n'
export KQ_SENDERS="${SENDERS[*]}"
export KQ_RCPTS="${RCPTS[*]}"
export KQ_SUBJECTS="${SUBJECTS[*]}"
export KQ_CONTENTS="${CONTENTS[*]}"
export KQ_REGEX="$USE_REGEX"
}
have_filter() {
(( ${#SENDERS[@]} + ${#RCPTS[@]} + ${#SUBJECTS[@]} + ${#CONTENTS[@]} > 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: basis<TAB>umschlag<TAB>from<TAB>betreff
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 <basis>.* 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