↓ Zum Hauptinhalt springen
  1. Beiträge/
  2. Kleines Homelab/
  3. k3s-prod: Kubernetes Cluster Konfiguration und Anwendungsbereitstellung/

parsedmarc

Inhaltsverzeichnis

parsedmarc
#

parsedmarc liest die DMARC-Aggregat- und Fehlerberichte sowie die SMTP-TLS-Berichte (RFC 8460) aus dem DMARC-Postfach (Server und Benutzer in values.yaml), reichert sie um Land, Reverse-DNS und Absendername der Quell-IP an und schreibt sie in PostgreSQL. Angezeigt werden sie in Grafana aus apps/prometheus, Dashboard “DMARC Reports”.

Die App ersetzt dmarc-report, die am 21.10.2026 abgeschaltet und danach entfernt wurde: deren Image läuft auf PHP 8.1, für das es gegen PHP2Shell keinen Fix gibt, und Image, Viewer und Parser werden seit 2023 nicht mehr gepflegt.

Quelle
#

  • Image: ghcr.io/domainaware/parsedmarc, das offizielle Image des Projekts, mit allen Ausgaben einschließlich PostgreSQL. Tag und Digest in values.yaml, Renovate hebt beide.
  • Datenbank: Chart postgres der CloudPirates-Charts mit dem offiziellen Image postgres, Werte in values-database.yaml.
  • Dashboard: Grafana-DMARC_Reports-PostgreSQL.json aus dem parsedmarc-Repository, angepasst durch dashboards/build.sh.
  • Dokumentation: domainaware.github.io/parsedmarc

Komponenten
#

Komponente Art Aufgabe
parsedmarc Deployment, Recreate wartet per IMAP IDLE auf neue Berichte, verarbeitet sie und verschiebt sie danach; je Aufwachen ein Stapel von 10 Mails, alle 30 Sekunden
parsedmarc-postgres StatefulSet PostgreSQL 18, Datenbank parsedmarc, Volume parsedmarc-db (Longhorn)
parsedmarc-import Job, abgeschaltet einmaliges Einlesen der Altberichte, siehe Altdaten
grafana-dashboard-parsedmarc ConfigMap Dashboard, vom Grafana-Sidecar über das Label grafana_dashboard gefunden
grafana-datasource-parsedmarc Secret im Namespace prometheus Datenquelle parsedmarc für Grafana
parsedmarc-postgres NetworkPolicy nur parsedmarc und Grafana erreichen die Datenbank

Eine eigene Weboberfläche gibt es nicht, deshalb auch keinen Ingress.

Warum PostgreSQL und nicht OpenSearch oder Elasticsearch
#

parsedmarc kann nach Elasticsearch, OpenSearch, PostgreSQL und weitere Ziele schreiben. Die fertigen Dashboards des Projekts zielen vor allem auf Kibana und OpenSearch Dashboards, für Grafana gibt es je eines für Elasticsearch und für PostgreSQL. Den Ausschlag gaben Betrieb und Datenmenge:

  • Die Berichte sind wenige: in cdp rund 27.000 Mails seit 2017, in zyria rund 1.000. Eine PostgreSQL-Instanz kommt dafür mit wenigen hundert MB Speicher aus. OpenSearch oder Elasticsearch brauchen eine JVM mit mindestens 1 bis 2 GB Heap und Pflege von Indizes und Mappings.
  • Grafana läuft in beiden Clustern schon. Mit PostgreSQL entfällt Kibana oder OpenSearch Dashboards als zusätzliche Oberfläche mit eigener Anmeldung.
  • Die CloudPirates-Charts sind das gesetzte Muster für Datenbanken in diesen Repositories, ein Such-Cluster wäre ein neuer Baustein.
  • parsedmarc erkennt bereits gespeicherte Berichte in PostgreSQL und überspringt sie (ON CONFLICT). Erneutes Einlesen ist dadurch gefahrlos.

Nachteil: Die Kibana-Dashboards mit Karten und Volltextsuche entfallen, und das PostgreSQL-Backend ist in parsedmarc jünger als das für Elasticsearch.

Konfiguration
#

parsedmarc läuft ohne INI-Datei, nur mit Umgebungsvariablen der Form PARSEDMARC_<ABSCHNITT>_<SCHLÜSSEL> in values.yaml. Die Bedeutung jedes Schlüssels steht in der Dokumentation.

Einstellung Wert Grund
Postfach PARSEDMARC_IMAP_*, IMAPS auf Port 993 dasselbe Postfach wie bisher bei dmarc-report
Eingang INBOX
Archiv Archive, darunter Aggregate, Failure, SMTP-TLS, Invalid Mails bleiben erhalten, die Datenbank lässt sich aus ihnen neu aufbauen
ALWAYS_USE_LOCAL_FILES true IP-Datenbank, Reverse-DNS-Zuordnung und PSL-Ausnahmen kommen aus dem Image statt beim Start von GitHub und IPinfo
NAMESERVERS ClusterIP des Service kube-dns ohne die Angabe fragt parsedmarc die öffentlichen Resolver von Cloudflare

SMTP-TLS-Berichte kommen nur an, wenn der TLSRPT-Eintrag _smtp._tls der Domains (rua=mailto:...) auf dasselbe Postfach zeigt wie der DMARC-Eintrag. Die Einträge stehen in apps/mailstack/dns-records.yaml.

Datenabfluss: Außer IMAP und DNS baut parsedmarc keine Verbindungen nach außen auf. Die Rückwärtsauflösung der Quell-IPs läuft über die Cluster-Resolver.

Das Deployment hat keine Probes. parsedmarc bietet keinen HTTP-Endpunkt und keinen Befehl, der den Zustand der IMAP-Verbindung meldet. Bricht die Verbindung dauerhaft ab, beendet sich der Prozess, und Kubernetes startet den Container neu.

Secrets
#

Alle Zugangsdaten stehen im secretGenerator von kustomization.yaml:

Secret Namespace Schlüssel Zweck
parsedmarc parsedmarc postgres-password Superuser postgres
CUSTOM_USER, CUSTOM_DB, CUSTOM_PASSWORD Rolle und Datenbank parsedmarc, Eigentümer der Tabellen
GRAFANA_PASSWORD Rolle grafana, nur SELECT
IMAP_PASSWORD DMARC-Postfach
grafana-datasource-parsedmarc prometheus parsedmarc.yaml Datenquelle mit dem Passwort der Rolle grafana, als JSON in einer Zeile

PostgreSQL wertet die Passwörter nur beim Anlegen des leeren Datenverzeichnisses aus. Danach ändert man sie mit ALTER ROLE in der Datenbank und im Secret.

Angelegt wurden sie mit diesem Befehl, der die Werte erzeugt, ohne sie auszugeben. Das IMAP-Passwort übernimmt er aus dem vorhandenen Eintrag; beim ersten Anlegen kam es aus der inzwischen entfernten App dmarc-report. Er setzt .secretGenerator neu und überschreibt einen vorhandenen Eintrag, für ein neu aufgesetztes Volume also nur zusammen mit einem leeren Datenverzeichnis verwenden:

 1cd "$(git rev-parse --show-toplevel)/apps/parsedmarc" \
 2  && export IMAP_PW="$(yq '.secretGenerator[] | select(.name == "parsedmarc") | .literals[] | select(test("^IMAP_PASSWORD=")) | sub("^IMAP_PASSWORD="; "")' kustomization.yaml)" \
 3  && export ADMIN_PW="$(openssl rand -hex 24)" OWNER_PW="$(openssl rand -hex 24)" GRAFANA_PW="$(openssl rand -hex 24)" \
 4  && export DS="$(jq -cn --arg pw "${GRAFANA_PW}" '{apiVersion: 1, datasources: [{name: "parsedmarc", uid: "parsedmarc", type: "grafana-postgresql-datasource", access: "proxy", url: "parsedmarc-postgres.parsedmarc.svc:5432", user: "grafana", editable: false, jsonData: {database: "parsedmarc", sslmode: "disable", postgresVersion: 1800}, secureJsonData: {password: $pw}}]}')" \
 5  && test -n "${IMAP_PW}" \
 6  && yq -i '.secretGenerator = [
 7       {"name": "parsedmarc", "namespace": "parsedmarc", "literals": [
 8         "postgres-password=" + strenv(ADMIN_PW),
 9         "CUSTOM_USER=parsedmarc",
10         "CUSTOM_DB=parsedmarc",
11         "CUSTOM_PASSWORD=" + strenv(OWNER_PW),
12         "GRAFANA_PASSWORD=" + strenv(GRAFANA_PW),
13         "IMAP_PASSWORD=" + strenv(IMAP_PW)]},
14       {"name": "grafana-datasource-parsedmarc", "namespace": "prometheus",
15        "options": {"labels": {"grafana_datasource": "1"}},
16        "literals": ["parsedmarc.yaml=" + strenv(DS)]}]
17     | (.secretGenerator[].literals[] | select(test("PASSWORD=|password=|parsedmarc.yaml="))) line_comment = "gitleaks:allow"' kustomization.yaml \
18  && unset IMAP_PW ADMIN_PW OWNER_PW GRAFANA_PW DS \
19  && git diff --stat kustomization.yaml

Grafana
#

Das Dashboard liegt als ConfigMap im Namespace parsedmarc. Der Sidecar von Grafana sucht in allen Namespaces nach dem Label grafana_dashboard, der für Datenquellen nur im Namespace prometheus. Deshalb legt diese App das Secret der Datenquelle dort an.

Die Datenquelle meldet sich als Rolle grafana an, die nur lesen darf. Das Init-Skript in values-database.yaml legt sie beim ersten Start an und gibt ihr per ALTER DEFAULT PRIVILEGES Leserechte auch auf die Tabellen, die parsedmarc erst später anlegt.

Das Dashboard enthält je Absenderdomain (Variable fromdomain):

  • Zusammenfassung: Volumen, SPF-, DKIM- und DMARC-Ergebnis, Disposition, jeweils auch im Zeitverlauf
  • Quellen nach IP, Reverse-DNS, Absender, Land und meldender Organisation
  • “Failing Sources”: Quellen, deren Mails DMARC nicht bestanden haben, mit SPF- und DKIM-Ergebnis (in diesem Repository ergänzt)
  • veröffentlichte Richtlinien, SPF- und DKIM-Details, Fehlerberichte
  • SMTP-TLS-Berichte: Sitzungen, Erfolg und Fehler, Richtlinien, Fehlerdetails

dashboards/parsedmarc.json wird nicht von Hand bearbeitet, sondern von dashboards/build.sh erzeugt. Das Skript lädt das Upstream-Dashboard in der Version des Image-Tags aus values.yaml, bindet alle Panels an die Datenquelle parsedmarc und fügt das Panel aus dashboards/failing-sources.sql ein. Nach jedem Update des Images:

1apps/parsedmarc/dashboards/build.sh

Das Skript braucht curl, jq und yq. Ändert sich die JSON-Datei, gehört sie in dieselbe PR wie das Image-Update, damit Dashboard und Schema zusammenpassen.

Altdaten
#

Die alte App hat verarbeitete Mails nicht gelöscht, sondern in die Ordner processed und error verschoben. Stand 21.10.2026 liegen dort 985 und 18 Mails vom 29.09.2017 bis 18.10.2026. Neue Berichte, die seit dem Abschalten eingegangen sind, liegen in INBOX und werden beim ersten Start verarbeitet.

Die Altberichte liest der Job parsedmarc-import ein. Er ist in values.yaml unter controllers.import mit enabled: false vorbereitet. Ein Einmallauf von parsedmarc verarbeitet nur einen Stapel, deshalb ruft das Skript des Jobs parsedmarc für processed und danach für error so lange auf, bis der Ordner leer ist, je 100 Mails pro Lauf. Die Mails wandern dabei wie neue Berichte nach Archive/..., nicht lesbare nach Archive/Invalid. Ein Stapel, der sich nicht speichern lässt, geht sofort nach Archive/Unsaved (MAX_UNSAVED_RETRIES 0), damit jeder Lauf vorankommt; macht ein Lauf keinen Fortschritt, bricht der Job ab. Bereits gespeicherte Berichte überspringt das PostgreSQL-Backend, ein zweiter Lauf schadet nicht.

  1. controllers.import.enabled auf true, Merge, Argo CD startet den Job.
  2. Fortschritt mit kubectl logs -n parsedmarc job/parsedmarc-import -f, die Zeilen import: nennen den Rest je Ordner.
  3. Nach dem Ende enabled wieder auf false. Argo CD entfernt den Job.

Danach sind processed und error leer und können gelöscht werden; alle Mails liegen unter Archive.

Die Daten in der MariaDB von dmarc-report werden nicht übernommen. Sie stammen aus denselben Mails, und ein Umbau des techsneeze-Schemas in das von parsedmarc brächte weniger Felder als das erneute Einlesen. Vor dem Rückbau der alten App am 21.10.2026 ist ihre Datenbank als Dump mit 972 Berichten (ab 2017) gesichert worden; danach wurden App, Namespace und Longhorn-Volume entfernt.

Betrieb
#

  • Logs: kubectl logs -n parsedmarc deploy/parsedmarc. Mails, die sich nicht parsen lassen, landen in Archive/Invalid. Mails, deren Berichte sich dreimal nicht speichern ließen, in Archive/Unsaved.
  • Datenbank neu aufbauen: Die Mails liegen vollständig im Postfach. Leeres Volume, dann den Import-Job auf die Ordner unter Archive richten (Schleife in values.yaml anpassen, etwa Archive/Aggregate).
  • Größe: Das Volume hat 2 GiB. Belegung prüfen mit kubectl exec -n parsedmarc parsedmarc-postgres-0 -- sh -c 'psql -U postgres -d parsedmarc -c "select pg_size_pretty(pg_database_size(current_database()))"'.

Installation
#

Die Anwendung wird mit dem app-template aus dem bjw-s-labs Repository bereitgestellt, die Datenbank mit dem Chart postgres der CloudPirates-Charts. Beide sind Einträge in der kustomization.yaml.

1kubectl kustomize --enable-helm apps/parsedmarc
Fabrice Kirchner
Autor
Fabrice Kirchner
stolzer Vater, Nerd, Admin