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 invalues.yaml, Renovate hebt beide. - Datenbank: Chart
postgresder CloudPirates-Charts mit dem offiziellen Imagepostgres, Werte invalues-database.yaml. - Dashboard:
Grafana-DMARC_Reports-PostgreSQL.jsonaus dem parsedmarc-Repository, angepasst durchdashboards/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.yamlGrafana #
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.shDas 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.
controllers.import.enabledauftrue, Merge, Argo CD startet den Job.- Fortschritt mit
kubectl logs -n parsedmarc job/parsedmarc-import -f, die Zeilenimport:nennen den Rest je Ordner. - Nach dem Ende
enabledwieder auffalse. 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 inArchive/Invalid. Mails, deren Berichte sich dreimal nicht speichern ließen, inArchive/Unsaved. - Datenbank neu aufbauen: Die Mails liegen vollständig im Postfach. Leeres
Volume, dann den Import-Job auf die Ordner unter
Archiverichten (Schleife invalues.yamlanpassen, etwaArchive/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