DNS-Recursor fuer rspamd + Bayes-Trainingsskript

Rspamds DNS-Blocklisten waren wirkungslos: Spamhaus beantwortete keine
Anfragen, weil sie ueber Dockers eingebautes DNS von einem als oeffentlich
eingestuften Resolver kamen (DBL_BLOCKED_OPENRESOLVER im Log, fuer rDNS,
Message-ID und URLs gleichermassen). ENABLE_UNBOUND=1 hat daran nichts
geaendert - die Variable existiert in DMS nicht und war ein No-Op.

- dns-recursor (PowerDNS Recursor 5.3.10) als eigener Service, der selbst
  rekursiv aufloest, in einem separaten Netz dms_dns mit fester IP, weil
  Dockers dns:-Option nur IPs akzeptiert
- ENABLE_UNBOUND entfernt
- train_bayes.sh: einmaliges Anlernen des Bayes-Klassifikators aus den
  vorhandenen Postfaechern (Junk -> Spam, INBOX -> Ham). Bisher feuerte kein
  einziges BAYES_-Symbol, weil die noetigen 200 Lernvorgaenge je Klasse
  fehlten. Default ist ein Trockenlauf, gelernt wird nur mit --apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 23:43:02 +02:00
parent 0978154f43
commit 1ef0b96be4
2 changed files with 259 additions and 1 deletions

View File

@@ -91,7 +91,10 @@ services:
# Sicherheit
- ENABLE_FAIL2BAN=1
- ENABLE_UNBOUND=1
# ENABLE_UNBOUND entfernt: die Variable existiert in DMS nicht (mehr) und
# war wirkungslos - rspamd hat ueber Dockers eingebautes DNS (127.0.0.11)
# aufgeloest und bekam von Spamhaus nur DBL_BLOCKED_OPENRESOLVER zurueck.
# Ersetzt durch den dns-recursor-Service weiter unten.
# Sonstige
- ENABLE_MANAGESIEVE=0
@@ -130,11 +133,47 @@ services:
- NET_ADMIN
- SYS_PTRACE
restart: unless-stopped
# -------------------------------------------------------
# DNS: rspamd fragt DNS-Blocklisten (Spamhaus DBL/ZEN, SURBL) ab. Diese
# Dienste beantworten keine Anfragen, die ueber oeffentliche Resolver
# kommen - man bekommt DBL_BLOCKED_OPENRESOLVER statt eines Ergebnisses.
# Deshalb ein eigener rekursiver Resolver, siehe Service dns-recursor.
#
# ACHTUNG: Damit ersetzt der Recursor Dockers eingebautes DNS (127.0.0.11)
# fuer DIESEN Container. Der mailserver kann dann keine Container-Namen
# mehr aufloesen (roundcube-db, email-worker-ts, ...). Das ist hier
# unkritisch, weil er von sich aus nur nach aussen verbindet (SES-Relay).
# Faellt der Recursor aus, hat der mailserver kein DNS mehr - deshalb
# restart: always beim Recursor.
# -------------------------------------------------------
dns:
- 10.99.0.53
depends_on:
- dns-recursor
networks:
mail_network:
aliases:
- mailserver
- ${NODE_HOSTNAME}
dns_network:
# ---------------------------------------------------------
# Rekursiver DNS-Resolver ausschliesslich fuer den mailserver.
# Er fragt die Root-Server selbst ab, statt an einen Upstream-Resolver
# weiterzuleiten - nur so akzeptiert Spamhaus die Anfragen.
# Braucht keine Konfiguration: das Image laeuft per Default als Recursor
# und erlaubt Anfragen aus den RFC1918-Netzen (10.99.0.0/24 liegt darin).
# ---------------------------------------------------------
dns-recursor:
image: powerdns/pdns-recursor-53:5.3.10
container_name: dns-recursor
restart: always
stop_grace_period: 0s
networks:
dns_network:
ipv4_address: 10.99.0.53
roundcube:
image: roundcube/roundcubemail:latest
@@ -190,3 +229,16 @@ services:
networks:
mail_network:
external: true
# Eigenes Netz nur fuer mailserver <-> dns-recursor.
# Bewusst NICHT im externen mail_network: dort vergibt Docker die Adressen
# dynamisch, eine feste IP koennte mit einem anderen Container kollidieren.
# Das Subnetz muss fest sein, weil Dockers `dns:`-Option nur IPs akzeptiert.
# Falls 10.99.0.0/24 bei euch schon belegt ist (VPN, AWS-VPC), hier und bei
# `dns:` sowie `ipv4_address` oben gemeinsam auf ein freies Netz aendern.
dns_network:
name: dms_dns
ipam:
driver: default
config:
- subnet: 10.99.0.0/24

View File

@@ -0,0 +1,206 @@
#!/bin/bash
# ===========================================================================
# train_bayes.sh - Bayes-Klassifikator von rspamd aus den vorhandenen
# Postfaechern anlernen.
#
# Hintergrund: rspamd traegt mit BAYES_* erst dann zum Score bei, wenn genug
# gelernt wurde (Default: 200 Spam UND 200 Ham). Bis dahin liefert der
# Klassifikator gar nichts - genau das war im Log zu sehen, dort tauchte kein
# einziges BAYES_-Symbol auf. Statt monatelang auf manuelles Verschieben in
# den Junk-Ordner zu warten (das macht RSPAMD_LEARN=1 bereits automatisch),
# lernt dieses Skript einmalig aus dem, was ohnehin auf der Platte liegt.
#
# AUSFUEHRUNG - immer IM Container:
#
# docker exec mailserver bash /tmp/docker-mailserver/train_bayes.sh
# -> Trockenlauf: zaehlt nur, lernt nichts. Das ist der Default.
#
# docker exec mailserver bash /tmp/docker-mailserver/train_bayes.sh --apply
# -> lernt tatsaechlich
#
# Optionen:
# --apply Wirklich lernen (ohne das passiert nichts)
# --max N Max. Nachrichten je Postfach und Klasse (Default 400)
# --include-sent Gesendete Mails zusaetzlich als Ham lernen
# --only MUSTER Nur Postfaecher, deren Adresse MUSTER enthaelt
# --host HOST:PORT rspamd-Controller (Default 127.0.0.1:11334)
#
# Passwort: Falls der Controller eines verlangt (bei euch in
# rspamd/override.d/worker-controller.inc gesetzt), vorweg setzen:
# docker exec -e RSPAMC_PASSWORD='...' mailserver bash /tmp/.../train_bayes.sh
#
# WICHTIG zur Datenqualitaet:
# - Ham kommt aus der INBOX. Liegt dort unerkannter Spam, wird er als Ham
# gelernt und verschlechtert die Erkennung. Vorher grob durchsehen lohnt.
# - Spam kommt aus Junk. Liegt dort faelschlich einsortierte legitime Mail,
# gilt dasselbe umgekehrt.
# - Ein sehr schiefes Verhaeltnis (z.B. 8000 Ham zu 50 Spam) macht den
# Klassifikator einseitig. Der Trockenlauf zeigt das Verhaeltnis an -
# danach ggf. mit --max nachsteuern.
# ===========================================================================
set -uo pipefail
MAILBASE="${MAILBASE:-/var/mail}"
RSPAMC_HOST="127.0.0.1:11334"
MAX_PER_CLASS=400
MAX_BYTES=5242880 # 5 MB - groessere Mails bringen fuers Lernen nichts
BATCH=50 # Dateien pro rspamc-Aufruf
APPLY=0
INCLUDE_SENT=0
ONLY=""
usage() { sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ; }
while [[ $# -gt 0 ]]; do
case "$1" in
--apply) APPLY=1; shift ;;
--max) MAX_PER_CLASS="${2:-}"; shift 2 ;;
--include-sent) INCLUDE_SENT=1; shift ;;
--only) ONLY="${2:-}"; shift 2 ;;
--host) RSPAMC_HOST="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unbekannte Option: $1" >&2; echo "Hilfe: $0 --help" >&2; exit 1 ;;
esac
done
if [[ ! -d ${MAILBASE} ]]; then
echo "FEHLER: ${MAILBASE} existiert nicht. Laeuft das Skript wirklich IM Container?" >&2
exit 1
fi
RSPAMC=(rspamc -h "${RSPAMC_HOST}")
[[ -n ${RSPAMC_PASSWORD:-} ]] && RSPAMC+=(-P "${RSPAMC_PASSWORD}")
if ! command -v rspamc >/dev/null 2>&1; then
echo "FEHLER: rspamc nicht gefunden." >&2
exit 1
fi
if ! "${RSPAMC[@]}" stat >/dev/null 2>&1; then
echo "FEHLER: Controller unter ${RSPAMC_HOST} nicht erreichbar oder Passwort noetig." >&2
echo " Versuche es mit: docker exec -e RSPAMC_PASSWORD='...' mailserver bash $0 ..." >&2
exit 1
fi
# ---------------------------------------------------------------------------
# Dateien einsammeln: bis LIMIT regulaere Dateien unterhalb der uebergebenen
# Maildir-Verzeichnisse, groessenbegrenzt. Neueste zuerst waere schoener, ist
# aber bei zehntausenden Dateien zu teuer - Maildir-Namen beginnen mit dem
# Timestamp, daher liefert die Sortierung eine brauchbare Naeherung.
# ---------------------------------------------------------------------------
gather() {
local limit="$1"; shift
local dir
for dir in "$@"; do
[[ -d ${dir} ]] || continue
find "${dir}" -maxdepth 1 -type f -size -"${MAX_BYTES}"c -print 2>/dev/null
done | sort -r | head -n "${limit}"
}
# ---------------------------------------------------------------------------
# Lernen in Batches. Gibt "gelernt fehlgeschlagen" zurueck.
# ---------------------------------------------------------------------------
learn() {
local cmd="$1"; shift
local -a files=("$@")
local total=${#files[@]}
local ok=0 fail=0 idx=0 out
while (( idx < total )); do
out=$("${RSPAMC[@]}" "${cmd}" "${files[@]:idx:BATCH}" 2>&1)
ok=$(( ok + $(grep -c 'success = true' <<<"${out}") ))
fail=$(( fail + $(grep -ci 'error' <<<"${out}") ))
idx=$(( idx + BATCH ))
done
echo "${ok} ${fail}"
}
# ---------------------------------------------------------------------------
# Hauptlauf
# ---------------------------------------------------------------------------
if (( APPLY )); then
echo "=== Bayes-Training (SCHREIBEND) ==="
else
echo "=== Bayes-Training (Trockenlauf - es wird nichts gelernt) ==="
fi
echo "Basis: ${MAILBASE} | max ${MAX_PER_CLASS} je Postfach und Klasse"
[[ -n ${ONLY} ]] && echo "Filter: nur Postfaecher mit '${ONLY}'"
printf '\n%-42s %8s %8s\n' "POSTFACH" "SPAM" "HAM"
printf -- '---------------------------------------------------------------\n'
total_spam=0
total_ham=0
learned_spam=0
learned_ham=0
failed=0
for domain_dir in "${MAILBASE}"/*/; do
[[ -d ${domain_dir} ]] || continue
domain=$(basename "${domain_dir}")
for user_dir in "${domain_dir}"*/; do
user_dir="${user_dir%/}"
[[ -d ${user_dir}/cur ]] || continue # keine Maildir -> ueberspringen
mailbox="$(basename "${user_dir}")@${domain}"
[[ -n ${ONLY} && ${mailbox} != *"${ONLY}"* ]] && continue
mapfile -t spam_files < <(gather "${MAX_PER_CLASS}" \
"${user_dir}/.Junk/cur" "${user_dir}/.Junk/new" \
"${user_dir}/.Spam/cur" "${user_dir}/.Spam/new")
ham_dirs=("${user_dir}/cur" "${user_dir}/new")
if (( INCLUDE_SENT )); then
ham_dirs+=("${user_dir}/.Sent/cur" "${user_dir}/.Sent Messages/cur")
fi
mapfile -t ham_files < <(gather "${MAX_PER_CLASS}" "${ham_dirs[@]}")
n_spam=${#spam_files[@]}
n_ham=${#ham_files[@]}
(( n_spam == 0 && n_ham == 0 )) && continue
printf '%-42s %8d %8d\n' "${mailbox}" "${n_spam}" "${n_ham}"
total_spam=$(( total_spam + n_spam ))
total_ham=$(( total_ham + n_ham ))
if (( APPLY )); then
if (( n_spam > 0 )); then
read -r ok fail <<<"$(learn learn_spam "${spam_files[@]}")"
learned_spam=$(( learned_spam + ok )); failed=$(( failed + fail ))
fi
if (( n_ham > 0 )); then
read -r ok fail <<<"$(learn learn_ham "${ham_files[@]}")"
learned_ham=$(( learned_ham + ok )); failed=$(( failed + fail ))
fi
fi
done
done
printf -- '---------------------------------------------------------------\n'
printf '%-42s %8d %8d\n\n' "SUMME" "${total_spam}" "${total_ham}"
if (( total_spam > 0 )); then
ratio=$(( total_ham / (total_spam > 0 ? total_spam : 1) ))
if (( ratio >= 5 )); then
echo "WARNUNG: Ham/Spam-Verhaeltnis etwa ${ratio}:1 - sehr einseitig."
echo " Besser mit kleinerem --max fahren, z.B. --max $(( total_spam / 4 + 10 ))."
echo
fi
fi
if (( ! APPLY )); then
echo "Trockenlauf beendet. Zum echten Lernen dasselbe Kommando mit --apply."
echo "rspamd braucht je Klasse mindestens 200 Nachrichten, bevor BAYES_* feuert."
exit 0
fi
echo "Gelernt: ${learned_spam} Spam, ${learned_ham} Ham, ${failed} Fehler/Duplikate."
echo
echo "=== rspamc stat ==="
"${RSPAMC[@]}" stat 2>/dev/null | grep -iE 'learn|classifier|statfile' || true
echo
echo "Kontrolle an der naechsten eingehenden Mail:"
echo " docker exec mailserver sh -c 'grep rspamd_task_write_log /var/log/mail/rspamd.log | tail -3'"
echo "In der Symbolliste sollte jetzt BAYES_HAM oder BAYES_SPAM auftauchen."