Add documentation and service scripts for DMARC reporting.

This commit is contained in:
2025-11-12 17:22:11 +01:00
parent cb9e958d39
commit 3b60d1b83e
5 changed files with 1360 additions and 0 deletions

View File

@@ -0,0 +1,616 @@
# 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 root -g root -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`:
```bash
dmarc-reports@oopen.de reports@oopen.de, dmarc-pipe:
```
Dann:
```bash
sudo postmap /etc/postfix/virtual
sudo systemctl reload postfix
```
> **Merke:** `/etc/postfix/transport` = ZustellWEG, `/etc/postfix/virtual` = 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 install -m 750 -o vmail -g vmail /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