diff --git a/docs/src/monitoring.md b/docs/src/monitoring.md index f67f918..a8f8858 100644 --- a/docs/src/monitoring.md +++ b/docs/src/monitoring.md @@ -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 diff --git a/machines/control/configuration.nix b/machines/control/configuration.nix index 1ff33ee..55d558f 100644 --- a/machines/control/configuration.nix +++ b/machines/control/configuration.nix @@ -10,6 +10,7 @@ in ../../modules/monitoring/server.nix ../../modules/monitoring/blackbox.nix ../../modules/monitoring/alerts.nix + ../../modules/monitoring/parsedmarc.nix ../../modules/docs.nix ]; diff --git a/modules/dns/zones/cnx.email.zone b/modules/dns/zones/cnx.email.zone index c6d37bb..82b2855 100644 --- a/modules/dns/zones/cnx.email.zone +++ b/modules/dns/zones/cnx.email.zone @@ -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. diff --git a/modules/mail-dmarc-cred.nix b/modules/mail-dmarc-cred.nix new file mode 100644 index 0000000..1fb1522 --- /dev/null +++ b/modules/mail-dmarc-cred.nix @@ -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 + ''; + }; +} diff --git a/modules/mail.nix b/modules/mail.nix index 5b5e9b6..480af79 100644 --- a/modules/mail.nix +++ b/modules/mail.nix @@ -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 diff --git a/modules/monitoring/parsedmarc.nix b/modules/monitoring/parsedmarc.nix new file mode 100644 index 0000000..b2bc944 --- /dev/null +++ b/modules/monitoring/parsedmarc.nix @@ -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; + }; + }; + }; +}