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

17 KiB
Raw Permalink Blame History

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:

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:

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:

dmarc-reports@oopen.de   dmarc-pipe:

Aktivieren:

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:

dmarc-reports@oopen.de   dmarc-reports-mbox@oopen.de

Dann:

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

_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:

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:

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:

#!/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

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:

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:

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:

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

/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

sudo -u vmail /usr/local/bin/dmarc-collect.sh < testmail.eml

Autor: oopen.de / Systemkonfiguration Stand: November 2025 Version: 1.2