# 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, pkgs, ... }: let mesh = import ../mesh-hosts.nix { inherit config lib; }; # Grafana 13 (since the externalisation of core data sources) no longer bundles # the Elasticsearch data source, and nixpkgs has no grafanaPlugins entry for it, # so a bare elasticsearch datasource reports "Plugin not registered" and every # DMARC panel fails. Pull the official, Grafana-signed plugin from the catalog # and load it declaratively. elasticsearchPlugin = pkgs.grafanaPlugins.grafanaPlugin { pname = "elasticsearch"; version = "12.6.4"; zipHash.x86_64-linux = "sha256-xyAUprdWyQM0IJSg/oBVZ0ltFAAffgPrphln9+IKcUY="; }; 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"; services.grafana.declarativePlugins = [ elasticsearchPlugin ]; # 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 = { # We provision the two Elasticsearch datasources ourselves (below) so we # can pin their UIDs. Leaving this on would add a second, uid-less pair # and collide on the dmarc-ag/dmarc-fo names. datasource = false; 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; }; }; }; # The bundled parsedmarc dashboard selects its datasource through template # variables ($datasourceag/$datasourcefo) whose stored value is the string # "dmarc-ag"/"dmarc-fo". Grafana 11+ resolves a datasource template variable # by UID, not by name, so without a matching UID every panel renders # "datasource was not found". Pin the UIDs to those names so it resolves. services.grafana.provision.datasources.settings = { # These two were previously provisioned uid-less, so they exist in Grafana's # DB with random UIDs. Provisioning a *new* UID onto an existing datasource # makes Grafana abort with "data source not found" and crash-loop, so delete # the stale records first; the entries below re-create them with pinned UIDs. deleteDatasources = [ { name = "dmarc-ag"; orgId = 1; } { name = "dmarc-fo"; orgId = 1; } ]; datasources = let esVersion = lib.getVersion config.services.elasticsearch.package; es = name: { inherit name; uid = name; type = "elasticsearch"; access = "proxy"; url = "http://localhost:9200"; jsonData = { timeField = "date_range"; inherit esVersion; }; }; in [ (es "dmarc-ag") (es "dmarc-fo") ]; }; }