Add parsedmarc DMARC report analyzer on control

Deliver cnx.email DMARC aggregate/forensic reports to a dedicated dmarc@cnx.email
mailbox on mx1 and analyze them with parsedmarc on control, storing parsed
reports in a local loopback Elasticsearch and visualizing via the auto-provisioned
Grafana dashboard. parsedmarc fetches the mailbox over IMAPS across the mesh
(mx1.cnx.email pinned to its mesh address so TLS still validates), using a shared
mail-dmarc-cred clan var so mx1's mailserver and control see the same password.
This commit is contained in:
Berwn
2026-06-21 03:27:23 +07:00
parent b8bea27a9c
commit 60db8c60b0
6 changed files with 131 additions and 8 deletions
+21
View File
@@ -52,6 +52,27 @@ there is picked up):
- **CNX Uptime** (`uptime.json`) — per-host up/down status, current uptime,
availability over the selected window, and up/down history. Label-driven, so
every scraped host appears automatically.
- **parsedmarc** — DMARC aggregate/forensic report viewer. Auto-provisioned by
the `parsedmarc` module (not from `dashboards/`); reads its own Elasticsearch
datasource, not VictoriaMetrics. See [DMARC reports](#dmarc-reports) below.
## DMARC reports
The `cnx.email` DMARC record (`rua`/`ruf`) points at the `dmarc@cnx.email`
mailbox on `mx1`. **parsedmarc** on `control` (`modules/monitoring/parsedmarc.nix`)
polls that mailbox over IMAPS, parses the XML reports, and stores them in a local
**Elasticsearch** (`127.0.0.1:9200`, loopback-only); Grafana renders them via the
auto-provisioned parsedmarc dashboard + Elasticsearch datasource.
The IMAP fetch rides the **mesh**, not the public net: `control` pins
`mx1.cnx.email` to mx1's mesh address in `/etc/hosts`, so TLS still validates
against the public cert while the bytes stay on the overlay. The mailbox
passphrase is the shared `mail-dmarc-cred` clan var (so both mx1's mailserver and
control's parsedmarc see the same value):
```
clan vars get mx1 mail-dmarc-cred/passphrase
```
## Logs
+1
View File
@@ -10,6 +10,7 @@ in
../../modules/monitoring/server.nix
../../modules/monitoring/blackbox.nix
../../modules/monitoring/alerts.nix
../../modules/monitoring/parsedmarc.nix
../../modules/docs.nix
];
+4 -1
View File
@@ -20,7 +20,10 @@ mx1 IN AAAA 2a01:4ff:2f0:1963::1
mail IN CNAME mx1.cnx.email.
@ IN MX 10 mx1.cnx.email.
@ IN TXT "v=spf1 mx -all"
_dmarc IN TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@cnx.email"
; Aggregate (rua) + forensic (ruf) reports go to the dmarc@cnx.email mailbox,
; which parsedmarc on control polls and feeds into Grafana. fo=1 asks reporters
; to send a forensic report on any SPF/DKIM failure.
_dmarc IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@cnx.email; ruf=mailto:dmarc@cnx.email; fo=1"
; ---- DANE / TLSA ----
; "3 1 1" = DANE-EE, SPKI, SHA-256: the digest of mx1's certificate public key.
+26
View File
@@ -0,0 +1,26 @@
# Shared credential for the dmarc@cnx.email mailbox.
#
# DMARC aggregate/forensic reports are delivered to dmarc@cnx.email on mx1;
# parsedmarc on control fetches them over IMAPS across the mesh and needs the
# *plaintext* passphrase, while mx1's mailserver only needs the sha-512 hash.
# clan vars secrets are per-machine, so this generator is shared (share = true)
# to make the same value available on both hosts. Files are root-owned: SNM reads
# the hash as root, and parsedmarc's ExecStartPre reads the passphrase as root.
# Imported by mx1 (via mail.nix) and control (via monitoring/parsedmarc.nix).
{ pkgs, ... }:
{
clan.core.vars.generators.mail-dmarc-cred = {
share = true;
files."passphrase".secret = true; # read by parsedmarc on control
files."hash".secret = true; # consumed by the mailserver on mx1
runtimeInputs = [
pkgs.xkcdpass
pkgs.mkpasswd
];
script = ''
pass="$(xkcdpass --numwords=4 --delimiter=- --case=lower)-$((RANDOM % 90 + 10))"
printf '%s' "$pass" > "$out"/passphrase
printf '%s' "$pass" | mkpasswd -s -m sha-512 > "$out"/hash
'';
};
}
+19 -7
View File
@@ -60,15 +60,27 @@ let
}) accounts
);
loginAccounts = lib.listToAttrs (
map (addr: {
name = addr;
value.hashedPasswordFile = config.clan.core.vars.generators.${genName addr}.files."hash".path;
}) accounts
);
loginAccounts =
lib.listToAttrs (
map (addr: {
name = addr;
value.hashedPasswordFile = config.clan.core.vars.generators.${genName addr}.files."hash".path;
}) accounts
)
// {
# DMARC report inbox (rua/ruf target in the cnx.email zone). Its password
# comes from the *shared* mail-dmarc-cred generator instead of the per-machine
# set above, so parsedmarc on control can read the same passphrase over the
# mesh. Retrieve it with: clan vars get mx1 mail-dmarc-cred/passphrase
"dmarc@cnx.email".hashedPasswordFile =
config.clan.core.vars.generators.mail-dmarc-cred.files."hash".path;
};
in
{
imports = [ ./dns/acme-mx1-secret.nix ];
imports = [
./dns/acme-mx1-secret.nix
./mail-dmarc-cred.nix
];
clan.core.vars.generators = passwdGenerators // {
# Render the shared acme_mx1 TSIG secret into a lego rfc2136 env file. lego
+60
View File
@@ -0,0 +1,60 @@
# DMARC report analyzer, imported by control only. parsedmarc fetches the
# aggregate/forensic reports that land in the dmarc@cnx.email mailbox on mx1,
# parses the XML, and stores results in a local Elasticsearch; the official
# parsedmarc dashboard + an Elasticsearch datasource are auto-provisioned into
# the Grafana instance that server.nix already runs on this host.
#
# IMAP runs over the ZeroTier mesh, not the public net: we pin mx1.cnx.email to
# its mesh address in /etc/hosts so TLS still validates against the public
# Let's Encrypt cert (primary domain mx1.cnx.email) while the bytes stay on the
# overlay. The mailbox passphrase is the shared mail-dmarc-cred secret; parsedmarc
# reads it as root in its ExecStartPre, so root-owned (clan default) is fine.
{ config, lib, ... }:
let
mesh = import ../mesh-hosts.nix { inherit config lib; };
in
{
imports = [ ../mail-dmarc-cred.nix ];
# Elasticsearch 7.x is under the (unfree) Elastic License; allow just this one
# package rather than opening allowUnfree globally.
nixpkgs.config.allowUnfreePredicate = pkg: lib.getName pkg == "elasticsearch";
# Keep mx1's IMAP traffic on the mesh while presenting the public cert name.
networking.hosts.${mesh.hosts.mx1} = [ "mx1.cnx.email" ];
services.parsedmarc = {
enable = true;
provision = {
# Local Elasticsearch on 127.0.0.1:9200 (loopback; no firewall change).
# datasource + dashboard default to true once ES and Grafana are both on.
elasticsearch = true;
# GeoIP needs a MaxMind account/license key; skip it (reports still parse,
# just without source-IP geolocation).
geoIp = false;
grafana = {
datasource = true;
dashboard = true;
};
};
settings = {
imap = {
host = "mx1.cnx.email";
port = 993;
ssl = true;
user = "dmarc@cnx.email";
password = {
_secret = config.clan.core.vars.generators.mail-dmarc-cred.files."passphrase".path;
};
};
mailbox = {
watch = true; # IMAP IDLE: process reports as they arrive
delete = false; # archive processed reports, don't delete
};
general = {
save_aggregate = true;
save_forensic = true;
};
};
};
}