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:
@@ -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
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ in
|
||||
../../modules/monitoring/server.nix
|
||||
../../modules/monitoring/blackbox.nix
|
||||
../../modules/monitoring/alerts.nix
|
||||
../../modules/monitoring/parsedmarc.nix
|
||||
../../modules/docs.nix
|
||||
];
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user