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,
|
- **CNX Uptime** (`uptime.json`) — per-host up/down status, current uptime,
|
||||||
availability over the selected window, and up/down history. Label-driven, so
|
availability over the selected window, and up/down history. Label-driven, so
|
||||||
every scraped host appears automatically.
|
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
|
## Logs
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ in
|
|||||||
../../modules/monitoring/server.nix
|
../../modules/monitoring/server.nix
|
||||||
../../modules/monitoring/blackbox.nix
|
../../modules/monitoring/blackbox.nix
|
||||||
../../modules/monitoring/alerts.nix
|
../../modules/monitoring/alerts.nix
|
||||||
|
../../modules/monitoring/parsedmarc.nix
|
||||||
../../modules/docs.nix
|
../../modules/docs.nix
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ mx1 IN AAAA 2a01:4ff:2f0:1963::1
|
|||||||
mail IN CNAME mx1.cnx.email.
|
mail IN CNAME mx1.cnx.email.
|
||||||
@ IN MX 10 mx1.cnx.email.
|
@ IN MX 10 mx1.cnx.email.
|
||||||
@ IN TXT "v=spf1 mx -all"
|
@ 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 ----
|
; ---- DANE / TLSA ----
|
||||||
; "3 1 1" = DANE-EE, SPKI, SHA-256: the digest of mx1's certificate public key.
|
; "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
|
}) accounts
|
||||||
);
|
);
|
||||||
|
|
||||||
loginAccounts = lib.listToAttrs (
|
loginAccounts =
|
||||||
map (addr: {
|
lib.listToAttrs (
|
||||||
name = addr;
|
map (addr: {
|
||||||
value.hashedPasswordFile = config.clan.core.vars.generators.${genName addr}.files."hash".path;
|
name = addr;
|
||||||
}) accounts
|
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
|
in
|
||||||
{
|
{
|
||||||
imports = [ ./dns/acme-mx1-secret.nix ];
|
imports = [
|
||||||
|
./dns/acme-mx1-secret.nix
|
||||||
|
./mail-dmarc-cred.nix
|
||||||
|
];
|
||||||
|
|
||||||
clan.core.vars.generators = passwdGenerators // {
|
clan.core.vars.generators = passwdGenerators // {
|
||||||
# Render the shared acme_mx1 TSIG secret into a lego rfc2136 env file. lego
|
# 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