Files
mailsystem/DOC/DMARC-Report/dmarc-server-setup.md

622 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# DMARC-Server-Sammelsystem Einrichtung und Betrieb
## 📖 Präambel
Dieses Dokument beschreibt die vollständige Einrichtung eines automatisierten DMARC-Report-Sammelsystems auf einem Linux-Mailserver mit **Postfix**, **Amavis** und **Dovecot (LMTP)**.
Ziel: DMARC-Aggregatberichte (XML, .gz, .zip) serverseitig empfangen, speichern und regelmäßig auswerten.
---
## 🎯 Ziel
- DMARC-Reports zentral über `dmarc-reports@oopen.de` empfangen.
- Reports automatisch extrahieren und datumsbasiert speichern.
- Regelmäßige automatische Auswertung mit `dmarc-scan.sh`.
- Speicherung der Ergebnisse in CSV-Dateien.
- Wartungsarm, sicher und robust.
---
## 📁 Verzeichnisstruktur
Alle Dateien werden unter `/var/lib/dmarc` abgelegt:
```
/var/lib/dmarc/
├── reports/ # Eingegangene XML-, GZ-, ZIP-Dateien
│ └── YYYY/MM/DD/ # Datumsbasierte Ablage
├── processed/ # Originalmails (Archiv)
├── exports/ # CSV- und Top-Auswertungen
└── logs/ # Logdateien
```
Verzeichnisse anlegen:
```bash
sudo install -d -o vmail -g vmail -m 750 /var/lib/dmarc/{reports,processed,exports,logs}
sudo install -d -o root -g root -m 750 /usr/local/lib/dmarc
```
---
## ⚙️ 1. Postfix-Integration
### 1.1 Transport und Master-Konfiguration
In `/etc/postfix/master.cf` **am Ende einfügen:**
```bash
dmarc-pipe unix - n n - - pipe
flags=Rq user=vmail argv=/usr/local/bin/dmarc-collect.sh
```
> Passe `user=` an, falls dein Mailbenutzer anders heißt (z.B. `mail` oder `amavis`).
### 1.2 Transportregel definieren
In `/etc/postfix/transport`:
```bash
dmarc-reports@oopen.de dmarc-pipe:
```
Aktivieren:
```bash
sudo postmap /etc/postfix/transport
sudo systemctl reload postfix
```
Damit weiß Postfix, dass Mails an diese Adresse an das Skript weitergegeben werden.
### 1.3 (Optional) Kopie der Mail behalten
Wenn du zusätzlich eine Kopie im IMAP-Postfach haben willst:
In `/etc/postfix/virtual_alias_maps`:
```bash
dmarc-reports@oopen.de dmarc-reports-mbox@oopen.de
```
Dann:
```bash
sudo postmap btree:/etc/postfix/virtual_alias_maps
sudo systemctl reload postfix
```
> **Merke:** `/etc/postfix/transport` = ZustellWEG, `/etc/postfix/virtual_alias_maps` = EmpfängerALIAS.
> Nur Skript? → Nur `transport`-Eintrag.
> Skript + Kopie? → `virtual`-Alias **zusätzlich**.
---
## 📨 2. DNS-Einträge (DMARC + External Reporting)
### Beispiel für eine Domain
```dns
_dmarc.fluechtlingsrat-brandenburg.de. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc-reports@oopen.de; ruf=mailto:dmarc-reports@oopen.de; fo=1; aspf=r; adkim=r"
```
### External Reporting (oopen.de)
Wenn du Berichte für mehrere Domains auf `@oopen.de` empfängst, erlaube externes Reporting:
```dns
oopen.de._report._dmarc.oopen.de. IN TXT "v=DMARC1"
*.oopen.de._report._dmarc.oopen.de. IN TXT "v=DMARC1"
```
---
## 🧰 3. Sammelskript `/usr/local/bin/dmarc-collect.sh`
**Datei anlegen:**
```bash
sudo tee /usr/local/bin/dmarc-collect.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
BASE="/var/lib/dmarc"
INBOX="$BASE/reports"
PROC="$BASE/processed"
LOGF="$BASE/logs/collector.log"
umask 027
TMPDIR="$(mktemp -d)"
EML="$TMPDIR/mail.eml"
cat > "$EML"
ripmime --no-nameless --name-by-type --overwrite -i "$EML" -d "$TMPDIR" >>"$LOGF" 2>&1 || true
TODAY="$(date -u +%Y/%m/%d)"
OUTDIR="$INBOX/$TODAY"
mkdir -p "$OUTDIR"
moved=0
shopt -s nullglob
for f in "$TMPDIR"/*; do
case "$f" in
*.xml|*.XML|*.gz|*.zip)
sha="$(sha256sum "$f" | awk '{print $1}')"
base="$(basename "$f")"
dst="$OUTDIR/$(date -u +%Y%m%dT%H%M%SZ)_${sha:0:12}_$base"
mv "$f" "$dst"
echo "$(date -Is) stored $dst" >> "$LOGF"
moved=$((moved+1))
;;
*) : ;;
esac
done
mkdir -p "$PROC"
mv "$EML" "$PROC/$(date -u +%Y%m%dT%H%M%SZ)_mail.eml" || true
rm -rf "$TMPDIR"
if (( moved > 0 )); then
exit 0
else
echo "$(date -Is) no usable attachment in message" >> "$LOGF"
exit 0
fi
EOF
sudo apt install -y ripmime
sudo chown vmail:vmail /usr/local/bin/dmarc-collect.sh
sudo chmod 750 /usr/local/bin/dmarc-collect.sh
```
Inhalt von `dmarc-collect.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
BASE="/var/lib/dmarc"
INBOX="$BASE/reports"
PROC="$BASE/processed"
LOGF="$BASE/logs/collector.log"
umask 027
TMPDIR="$(mktemp -d)"
EML="$TMPDIR/mail.eml"
cat > "$EML"
ripmime --no-nameless --name-by-type --overwrite -i "$EML" -d "$TMPDIR" >>"$LOGF" 2>&1 || true
TODAY="$(date -u +%Y/%m/%d)"
OUTDIR="$INBOX/$TODAY"
mkdir -p "$OUTDIR"
moved=0
shopt -s nullglob
for f in "$TMPDIR"/*; do
case "$f" in
*.xml|*.XML|*.gz|*.zip)
sha="$(sha256sum "$f" | awk '{print $1}')"
base="$(basename "$f")"
dst="$OUTDIR/$(date -u +%Y%m%dT%H%M%SZ)_${sha:0:12}_$base"
mv "$f" "$dst"
echo "$(date -Is) stored $dst" >> "$LOGF"
moved=$((moved+1))
;;
*) : ;;
esac
done
mkdir -p "$PROC"
mv "$EML" "$PROC/$(date -u +%Y%m%dT%H%M%SZ)_mail.eml" || true
rm -rf "$TMPDIR"
if (( moved > 0 )); then
exit 0
else
echo "$(date -Is) no usable attachment in message" >> "$LOGF"
exit 0
fi
```
---
## ⏱️ 4. Automatische tägliche Auswertung
**Datei:** `/usr/local/lib/dmarc/daily-run.sh`
```bash
sudo tee /usr/local/lib/dmarc/daily-run.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
BASE="/var/lib/dmarc"
TODAY_DIR="$BASE/reports/$(date -u +%Y/%m/%d)"
OUTDIR="$BASE/exports"
CSV="$OUTDIR/records.csv"
LOGF="$BASE/logs/scan-$(date -u +%F).log"
mkdir -p "$OUTDIR"
if [[ -d "$TODAY_DIR" ]]; then
/usr/local/bin/dmarc-scan.sh "$TODAY_DIR" --domain fluechtlingsrat-brandenburg.de --csv "$CSV" --append --top 25 --outdir "$OUTDIR" >> "$LOGF" 2>&1
fi
EOF
sudo chmod 750 /usr/local/lib/dmarc/daily-run.sh
```
Cronjob anlegen:
```bash
echo '17 3 * * * root /usr/local/lib/dmarc/daily-run.sh' | sudo tee /etc/cron.d/dmarc-daily >/dev/null
```
---
## 🧮 5. Auswertungsskript `/usr/local/bin/dmarc-scan.sh`
Installation:
```bash
sudo apt install -y xmlstarlet unzip gzip
sudo tee /usr/local/bin/dmarc-scan.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
#
# dmarc-scan.sh — DMARC-XML-Reports (auch .gz/.zip) einlesen, tabellarisch anzeigen,
# Records als CSV exportieren (append optional), Top-IPs ermitteln
# und Top-Listen als CSV schreiben.
#
# Nutzung:
# ./dmarc-scan.sh /pfad/zu/reports \
# [--domain DOMAIN] \
# [--csv pfad/zur/records.csv] \
# [--append] \
# [--top N] \
# [--outdir pfad/zum/ordner]
#
# Beispiele:
# ./dmarc-scan.sh /var/mail/dmarc
# ./dmarc-scan.sh /var/mail/dmarc --domain fluechtlingsrat-brandenburg.de --csv dmarc.csv --append --top 15 --outdir ./export
#
# Voraussetzungen: xmlstarlet, unzip (für .zip), gzip (für .gz)
# Debian/Ubuntu: sudo apt-get install xmlstarlet unzip gzip
#
set -euo pipefail
REPORT_DIR="${1:-}"
shift || true
# Defaults
WANT_DOMAIN=""
CSV_PATH="./dmarc-summary.csv"
APPEND=0
TOP_N=10
OUTDIR="."
# Arg-Parsing (einfach)
while [[ $# -gt 0 ]]; do
case "${1:-}" in
--domain)
WANT_DOMAIN="${2:-}"; shift 2 || true ;;
--csv)
CSV_PATH="${2:-}"; shift 2 || true ;;
--append)
APPEND=1; shift || true ;;
--top)
TOP_N="${2:-10}"; shift 2 || true ;;
--outdir)
OUTDIR="${2:-.}"; shift 2 || true ;;
*)
# Unbekannte Option ignorieren
shift || true ;;
esac
done
if [[ -z "$REPORT_DIR" || ! -d "$REPORT_DIR" ]]; then
echo "Fehler: Bitte ein Verzeichnis mit DMARC-Reports angeben."
echo "Beispiel: $0 /var/mail/dmarc --domain fluechtlingsrat-brandenburg.de --csv dmarc.csv --append --top 15 --outdir ./export"
exit 1
fi
if ! command -v xmlstarlet >/dev/null 2>&1; then
echo "Fehler: xmlstarlet nicht gefunden. Bitte installieren (z.B. 'sudo apt-get install xmlstarlet')."
exit 1
fi
mkdir -p "$OUTDIR"
# CSV-Header für Records; bei --append nur schreiben, wenn Datei noch nicht existiert
RECORDS_HEADER="report_org,policy_domain,begin_utc,end_utc,source_ip,count,disposition,spf,dkim,header_from"
ensure_records_header() {
if [[ "$APPEND" -eq 1 ]]; then
if [[ ! -f "$CSV_PATH" ]]; then
echo "$RECORDS_HEADER" > "$CSV_PATH"
fi
else
echo "$RECORDS_HEADER" > "$CSV_PATH"
fi
}
ensure_records_header
# Zähler & Sets
declare -A SEEN_IPS=()
declare -A IP_COUNTS=() # Summe pro IP
declare -A SPF_ONLY_FAIL_IP=() # nur SPF fail pro IP
declare -A DKIM_ONLY_FAIL_IP=() # nur DKIM fail pro IP
declare -A BOTH_FAIL_IP=() # SPF+DKIM fail pro IP
total_msgs=0
pass_msgs=0
fail_msgs=0
spf_only_fail=0
dkim_only_fail=0
both_fail=0
# Hilfsfunktion: Epoch -> Datum (UTC)
epoch2date() {
local e="$1"
if [[ -z "$e" ]]; then printf "-"; return; fi
date -u -d @"$e" +"%Y-%m-%d %H:%M:%S UTC" 2>/dev/null || printf "%s" "$e"
}
# CSV-escape (Felder in Anführungszeichen, doppelte Anführungszeichen verdoppeln)
csv_escape() {
local s="${1:-}"
s="${s//\"/\"\"}"
printf "\"%s\"" "$s"
}
# Eine einzelne XML-Datei parsen
parse_xml() {
local xml_input="$1"
# Domain aus policy_published, ggf. für Filter
local domain
domain=$(xmlstarlet sel -T -t -v "/feedback/policy_published/domain" "$xml_input" 2>/dev/null || true)
if [[ -n "$WANT_DOMAIN" && "$domain" != "$WANT_DOMAIN" ]]; then
return 0
fi
local org begin end pol sp aspf adkim
org=$(xmlstarlet sel -T -t -v "/feedback/report_metadata/org_name" "$xml_input" 2>/dev/null || printf "-")
begin=$(xmlstarlet sel -T -t -v "/feedback/report_metadata/date_range/begin" "$xml_input" 2>/dev/null || printf "")
end=$(xmlstarlet sel -T -t -v "/feedback/report_metadata/date_range/end" "$xml_input" 2>/dev/null || printf "")
pol=$(xmlstarlet sel -T -t -v "/feedback/policy_published/p" "$xml_input" 2>/dev/null || printf "-")
sp=$(xmlstarlet sel -T -t -v "/feedback/policy_published/sp" "$xml_input" 2>/dev/null || printf "-")
aspf=$(xmlstarlet sel -T -t -v "/feedback/policy_published/aspf" "$xml_input" 2>/dev/null || printf "-")
adkim=$(xmlstarlet sel -T -t -v "/feedback/policy_published/adkim" "$xml_input" 2>/dev/null || printf "-")
# Report-Header
echo "=============================================================================="
echo "Report von: ${org}"
echo "Domain (Policy): ${domain} (p=${pol}, sp=${sp}, aspf=${aspf}, adkim=${adkim})"
echo "Zeitraum: $(epoch2date "$begin") $(epoch2date "$end")"
echo "------------------------------------------------------------------------------"
printf "%-16s %7s %-10s %-6s %-6s %s\n" "Source IP" "Count" "Disposition" "SPF" "DKIM" "Header-From"
echo "------------------------------------------------------------------------------"
# Alle <record>-Einträge tabellarisch ausgeben
while IFS='|' read -r ip cnt dispo spfres dkimres hfrom; do
[[ -z "$ip$cnt$dispo$spfres$dkimres$hfrom" ]] && continue
local n=0
if [[ -n "${cnt:-}" && "$cnt" =~ ^[0-9]+$ ]]; then n="$cnt"; fi
total_msgs=$(( total_msgs + n ))
[[ -n "$ip" ]] && SEEN_IPS["$ip"]=1
[[ -n "$ip" ]] && IP_COUNTS["$ip"]=$(( ${IP_COUNTS["$ip"]:-0} + n ))
if [[ "${spfres:-}" == "pass" && "${dkimres:-}" == "pass" ]]; then
pass_msgs=$(( pass_msgs + n ))
else
fail_msgs=$(( fail_msgs + n ))
if [[ "${spfres:-}" != "pass" && "${dkimres:-}" == "pass" ]]; then
spf_only_fail=$(( spf_only_fail + n ))
[[ -n "$ip" ]] && SPF_ONLY_FAIL_IP["$ip"]=$(( ${SPF_ONLY_FAIL_IP["$ip"]:-0} + n ))
elif [[ "${spfres:-}" == "pass" && "${dkimres:-}" != "pass" ]]; then
dkim_only_fail=$(( dkim_only_fail + n ))
[[ -n "$ip" ]] && DKIM_ONLY_FAIL_IP["$ip"]=$(( ${DKIM_ONLY_FAIL_IP["$ip"]:-0} + n ))
else
both_fail=$(( both_fail + n ))
[[ -n "$ip" ]] && BOTH_FAIL_IP["$ip"]=$(( ${BOTH_FAIL_IP["$ip"]:-0} + n ))
fi
fi
printf "%-16s %7s %-10s %-6s %-6s %s\n" "${ip:--}" "${n}" "${dispo:--}" "${spfres:--}" "${dkimres:--}" "${hfrom:--}"
local begin_human end_human
begin_human="$(epoch2date "$begin")"
end_human="$(epoch2date "$end")"
{
csv_escape "$org"; printf ","
csv_escape "$domain"; printf ","
csv_escape "$begin_human"; printf ","
csv_escape "$end_human"; printf ","
csv_escape "${ip:-}"; printf ","
printf "%s," "$n"
csv_escape "${dispo:-}"; printf ","
csv_escape "${spfres:-}"; printf ","
csv_escape "${dkimres:-}"; printf ","
csv_escape "${hfrom:-}"; printf "\n"
} >> "$CSV_PATH"
done < <(xmlstarlet sel -T -t \
-m "/feedback/record" \
-v "row/source_ip" -o "|" \
-v "row/count" -o "|" \
-v "row/policy_evaluated/disposition" -o "|" \
-v "row/policy_evaluated/spf" -o "|" \
-v "row/policy_evaluated/dkim" -o "|" \
-v "identifiers/header_from" -n \
"$xml_input" 2>/dev/null)
echo
}
# Alle Dateien im Verzeichnis verarbeiten
shopt -s nullglob
for f in "$REPORT_DIR"/*; do
case "$f" in
*.xml)
parse_xml "$f"
;;
*.gz)
if command -v gzip >/dev/null 2>&1; then
tmp="$(mktemp)"
if gzip -cd "$f" > "$tmp"; then
parse_xml "$tmp"
else
echo "Warnung: Konnte $f nicht entpacken."
fi
rm -f "$tmp"
else
echo "Warnung: gzip nicht verfügbar, überspringe $f"
fi
;;
*.zip)
if command -v unzip >/dev/null 2>&1; then
tmpdir="$(mktemp -d)"
if unzip -qq -j "$f" '*.xml' -d "$tmpdir" >/dev/null 2>&1; then
for x in "$tmpdir"/*.xml; do
[[ -e "$x" ]] || continue
parse_xml "$x"
done
else
echo "Warnung: Konnte $f nicht entpacken (oder keine XML darin)."
fi
rm -rf "$tmpdir"
else
echo "Warnung: unzip nicht verfügbar, überspringe $f"
fi
;;
*) : ;;
esac
done
# Zusammenfassung
unique_ips=${#SEEN_IPS[@]}
echo "=============================================================================="
echo "GESAMT-ZUSAMMENFASSUNG"
echo "Nachrichten gesamt: $total_msgs"
echo "Eindeutige Source-IPs: $unique_ips"
echo "Alle Auth PASS: $pass_msgs"
echo "SPF/DKIM Fehler gesamt: $fail_msgs"
echo " ├─ nur SPF-Fail: $spf_only_fail"
echo " ├─ nur DKIM-Fail: $dkim_only_fail"
echo " └─ SPF+DKIM-Fail: $both_fail"
[[ -n "$WANT_DOMAIN" ]] && echo "Gefilterte Domain: $WANT_DOMAIN"
echo "Records-CSV: $CSV_PATH"
echo "Hinweis: 'Fail' umfasst Records, bei denen SPF oder DKIM nicht 'pass' war."
# Top-Listen auf STDOUT
echo
echo "TOP $TOP_N IPs nach Anzahl (über alle Reports):"
{
for ip in "${!IP_COUNTS[@]}"; do
printf "%10d %s\n" "${IP_COUNTS[$ip]}" "$ip"
done
} | sort -rn | head -n "$TOP_N" | awk '{printf " %2d) %-16s %7s\n", NR, $2, $1}'
if (( fail_msgs > 0 )); then
echo
echo "Top-IPs nur SPF-Fail:"
{ for ip in "${!SPF_ONLY_FAIL_IP[@]}"; do printf "%10d %s\n" "${SPF_ONLY_FAIL_IP[$ip]}" "$ip"; done; } \
| sort -rn | head -n "$TOP_N" | awk '{printf " %2d) %-16s %7s\n", NR, $2, $1}'
echo
echo "Top-IPs nur DKIM-Fail:"
{ for ip in "${!DKIM_ONLY_FAIL_IP[@]}"; do printf "%10d %s\n" "${DKIM_ONLY_FAIL_IP[$ip]}" "$ip"; done; } \
| sort -rn | head -n "$TOP_N" | awk '{printf " %2d) %-16s %7s\n", NR, $2, $1}'
echo
echo "Top-IPs SPF+DKIM-Fail:"
{ for ip in "${!BOTH_FAIL_IP[@]}"; do printf "%10d %s\n" "${BOTH_FAIL_IP[$ip]}" "$ip"; done; } \
| sort -rn | head -n "$TOP_N" | awk '{printf " %2d) %-16s %7s\n", NR, $2, $1}'
fi
# ---- CSV-Exporte der Top-Listen ---------------------------------------------
write_top_csv () {
local outfile="$1"; shift
local -n assoc="$1" # name reference auf assoziatives Array
echo "ip,count" > "$outfile"
if [[ "${#assoc[@]}" -eq 0 ]]; then
: # leer
else
for ip in "${!assoc[@]}"; do
printf "%s,%s\n" "$ip" "${assoc[$ip]}"
done | sort -t, -k2,2nr > "$outfile"
fi
}
# Gesamt-Top-IPs
write_top_csv "$OUTDIR/top_ips.csv" IP_COUNTS
# Top-Listen nach Fail-Kategorien
write_top_csv "$OUTDIR/top_spf_fail_ips.csv" SPF_ONLY_FAIL_IP
write_top_csv "$OUTDIR/top_dkim_fail_ips.csv" DKIM_ONLY_FAIL_IP
write_top_csv "$OUTDIR/top_both_fail_ips.csv" BOTH_FAIL_IP
echo
echo "Exportierte Top-CSV-Dateien:"
echo " $OUTDIR/top_ips.csv"
echo " $OUTDIR/top_spf_fail_ips.csv"
echo " $OUTDIR/top_dkim_fail_ips.csv"
echo " $OUTDIR/top_both_fail_ips.csv"
EOF
sudo chmod 750 /usr/local/bin/dmarc-scan.sh
```
Beschreibung: Das Skript liest XML/ZIP/GZ-Reports, zeigt eine Tabelle pro Report, schreibt eine Records-CSV (mit `--append` fortsetzbar) und exportiert Top-Listen als CSV in `--outdir`.
**Wichtige Parameter:**
- `--domain DOMAIN` (Filter)
- `--csv PFAD` (Records-CSV)
- `--append` (anhängen statt überschreiben)
- `--top N` (Top-Liste Größe)
- `--outdir PFAD` (Top-CSV Ziel)
**Beispiel:**
```bash
dmarc-scan.sh /var/lib/dmarc/reports/2025/11/12 --domain fluechtlingsrat-brandenburg.de --csv /var/lib/dmarc/exports/records.csv --append --top 25 --outdir /var/lib/dmarc/exports/
```
---
## 🔁 6. Logrotate
**Datei:** `/etc/logrotate.d/dmarc`
```bash
/var/lib/dmarc/logs/*.log {
weekly
rotate 12
compress
delaycompress
missingok
notifempty
create 640 vmail vmail
sharedscripts
postrotate
systemctl reload postfix >/dev/null 2>&1 || true
endscript
}
```
---
## ✅ 7. Test
```bash
sudo -u vmail /usr/local/bin/dmarc-collect.sh < testmail.eml
```
---
**Autor:** oopen.de / Systemkonfiguration
**Stand:** November 2025
**Version:** 1.2