1 { config, lib, options, pkgs, ... }:
4 cfg = config.services.parsedmarc;
5 opt = options.services.parsedmarc;
6 isSecret = v: isAttrs v && v ? _secret && isString v._secret;
7 ini = pkgs.formats.ini {
8 mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" rec {
10 if isInt v then toString v
11 else if isString v then v
12 else if true == v then "True"
13 else if false == v then "False"
14 else if isSecret v then hashString "sha256" v._secret
15 else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
18 inherit (builtins) elem isAttrs isString isInt isList typeOf hashString;
21 options.services.parsedmarc = {
23 enable = lib.mkEnableOption (lib.mdDoc ''
24 parsedmarc, a DMARC report monitoring service
29 enable = lib.mkOption {
30 type = lib.types.bool;
32 description = lib.mdDoc ''
33 Whether Postfix and Dovecot should be set up to receive
34 mail locally. parsedmarc will be configured to watch the
35 local inbox as the automatically created user specified in
36 [](#opt-services.parsedmarc.provision.localMail.recipientName)
40 recipientName = lib.mkOption {
43 description = lib.mdDoc ''
44 The DMARC mail recipient name, i.e. the name part of the
45 email address which receives DMARC reports.
47 A local user with this name will be set up and assigned a
48 randomized password on service start.
52 hostname = lib.mkOption {
54 default = config.networking.fqdn;
55 defaultText = lib.literalExpression "config.networking.fqdn";
56 example = "monitoring.example.com";
57 description = lib.mdDoc ''
58 The hostname to use when configuring Postfix.
60 Should correspond to the host's fully qualified domain
61 name and the domain part of the email address which
62 receives DMARC reports. You also have to set up an MX record
63 pointing to this domain name.
68 geoIp = lib.mkOption {
69 type = lib.types.bool;
71 description = lib.mdDoc ''
72 Whether to enable and configure the [geoipupdate](#opt-services.geoipupdate.enable)
73 service to automatically fetch GeoIP databases. Not crucial,
74 but recommended for full functionality.
76 To finish the setup, you need to manually set the [](#opt-services.geoipupdate.settings.AccountID) and
77 [](#opt-services.geoipupdate.settings.LicenseKey)
82 elasticsearch = lib.mkOption {
83 type = lib.types.bool;
85 description = lib.mdDoc ''
86 Whether to set up and use a local instance of Elasticsearch.
91 datasource = lib.mkOption {
92 type = lib.types.bool;
93 default = cfg.provision.elasticsearch && config.services.grafana.enable;
94 defaultText = lib.literalExpression ''
95 config.${opt.provision.elasticsearch} && config.${options.services.grafana.enable}
97 apply = x: x && cfg.provision.elasticsearch;
98 description = lib.mdDoc ''
99 Whether the automatically provisioned Elasticsearch
100 instance should be added as a grafana datasource. Has no
102 [](#opt-services.parsedmarc.provision.elasticsearch)
107 dashboard = lib.mkOption {
108 type = lib.types.bool;
109 default = config.services.grafana.enable;
110 defaultText = lib.literalExpression "config.services.grafana.enable";
111 description = lib.mdDoc ''
112 Whether the official parsedmarc grafana dashboard should
113 be provisioned to the local grafana instance.
119 settings = lib.mkOption {
120 example = lib.literalExpression ''
123 host = "imap.example.com";
124 user = "alice@example.com";
125 password = { _secret = "/run/keys/imap_password" };
129 url = "https://splunkhec.example.com";
130 token = { _secret = "/run/keys/splunk_token" };
135 description = lib.mdDoc ''
136 Configuration parameters to set in
137 {file}`parsedmarc.ini`. For a full list of
138 available parameters, see
139 <https://domainaware.github.io/parsedmarc/#configuration-file>.
141 Settings containing secret data should be set to an attribute
142 set containing the attribute `_secret` - a
143 string pointing to a file containing the value the option
144 should be set to. See the example to get a better picture of
145 this: in the resulting {file}`parsedmarc.ini`
146 file, the `splunk_hec.token` key will be set
147 to the contents of the
148 {file}`/run/keys/splunk_token` file.
151 type = lib.types.submodule {
152 freeformType = ini.type;
156 save_aggregate = lib.mkOption {
157 type = lib.types.bool;
159 description = lib.mdDoc ''
160 Save aggregate report data to Elasticsearch and/or Splunk.
164 save_forensic = lib.mkOption {
165 type = lib.types.bool;
167 description = lib.mdDoc ''
168 Save forensic report data to Elasticsearch and/or Splunk.
174 host = lib.mkOption {
175 type = lib.types.str;
176 default = "localhost";
177 description = lib.mdDoc ''
178 The IMAP server hostname or IP address.
182 port = lib.mkOption {
183 type = lib.types.port;
185 description = lib.mdDoc ''
186 The IMAP server port.
191 type = lib.types.bool;
193 description = lib.mdDoc ''
194 Use an encrypted SSL/TLS connection.
198 user = lib.mkOption {
199 type = with lib.types; nullOr str;
201 description = lib.mdDoc ''
202 The IMAP server username.
206 password = lib.mkOption {
207 type = with lib.types; nullOr (either path (attrsOf path));
209 description = lib.mdDoc ''
210 The IMAP server password.
212 Always handled as a secret whether the value is
213 wrapped in a `{ _secret = ...; }`
214 attrset or not (refer to [](#opt-services.parsedmarc.settings) for
217 apply = x: if isAttrs x || x == null then x else { _secret = x; };
220 watch = lib.mkOption {
221 type = lib.types.bool;
223 description = lib.mdDoc ''
224 Use the IMAP IDLE command to process messages as they arrive.
228 delete = lib.mkOption {
229 type = lib.types.bool;
231 description = lib.mdDoc ''
232 Delete messages after processing them, instead of archiving them.
238 host = lib.mkOption {
239 type = with lib.types; nullOr str;
241 description = lib.mdDoc ''
242 The SMTP server hostname or IP address.
246 port = lib.mkOption {
247 type = with lib.types; nullOr port;
249 description = lib.mdDoc ''
250 The SMTP server port.
255 type = with lib.types; nullOr bool;
257 description = lib.mdDoc ''
258 Use an encrypted SSL/TLS connection.
262 user = lib.mkOption {
263 type = with lib.types; nullOr str;
265 description = lib.mdDoc ''
266 The SMTP server username.
270 password = lib.mkOption {
271 type = with lib.types; nullOr (either path (attrsOf path));
273 description = lib.mdDoc ''
274 The SMTP server password.
276 Always handled as a secret whether the value is
277 wrapped in a `{ _secret = ...; }`
278 attrset or not (refer to [](#opt-services.parsedmarc.settings) for
281 apply = x: if isAttrs x || x == null then x else { _secret = x; };
284 from = lib.mkOption {
285 type = with lib.types; nullOr str;
287 description = lib.mdDoc ''
288 The `From` address to use for the
294 type = with lib.types; nullOr (listOf str);
296 description = lib.mdDoc ''
297 The addresses to send outgoing mail to.
303 hosts = lib.mkOption {
305 type = with lib.types; listOf str;
306 apply = x: if x == [] then null else lib.concatStringsSep "," x;
307 description = lib.mdDoc ''
308 A list of Elasticsearch hosts to push parsed reports
313 user = lib.mkOption {
314 type = with lib.types; nullOr str;
316 description = lib.mdDoc ''
317 Username to use when connecting to Elasticsearch, if
322 password = lib.mkOption {
323 type = with lib.types; nullOr (either path (attrsOf path));
325 description = lib.mdDoc ''
326 The password to use when connecting to Elasticsearch,
329 Always handled as a secret whether the value is
330 wrapped in a `{ _secret = ...; }`
331 attrset or not (refer to [](#opt-services.parsedmarc.settings) for
334 apply = x: if isAttrs x || x == null then x else { _secret = x; };
338 type = lib.types.bool;
340 description = lib.mdDoc ''
341 Whether to use an encrypted SSL/TLS connection.
345 cert_path = lib.mkOption {
346 type = lib.types.path;
347 default = "/etc/ssl/certs/ca-certificates.crt";
348 description = lib.mdDoc ''
349 The path to a TLS certificate bundle used to verify
350 the server's certificate.
361 config = lib.mkIf cfg.enable {
363 services.elasticsearch.enable = lib.mkDefault cfg.provision.elasticsearch;
365 services.geoipupdate = lib.mkIf cfg.provision.geoIp {
373 DatabaseDirectory = "/var/lib/GeoIP";
377 services.dovecot2 = lib.mkIf cfg.provision.localMail.enable {
379 protocols = [ "imap" ];
382 services.postfix = lib.mkIf cfg.provision.localMail.enable {
384 origin = cfg.provision.localMail.hostname;
386 myhostname = cfg.provision.localMail.hostname;
387 mydestination = cfg.provision.localMail.hostname;
392 declarativePlugins = with pkgs.grafanaPlugins;
393 lib.mkIf cfg.provision.grafana.dashboard [
394 grafana-worldmap-panel
395 grafana-piechart-panel
399 enable = cfg.provision.grafana.datasource || cfg.provision.grafana.dashboard;
402 esVersion = lib.getVersion config.services.elasticsearch.package;
404 lib.mkIf cfg.provision.grafana.datasource [
407 type = "elasticsearch";
409 url = "http://localhost:9200";
411 timeField = "date_range";
417 type = "elasticsearch";
419 url = "http://localhost:9200";
421 timeField = "date_range";
426 dashboards = lib.mkIf cfg.provision.grafana.dashboard [{
428 options.path = "${pkgs.python3Packages.parsedmarc.dashboard}";
433 services.parsedmarc.settings = lib.mkMerge [
434 (lib.mkIf cfg.provision.elasticsearch {
436 hosts = [ "localhost:9200" ];
440 (lib.mkIf cfg.provision.localMail.enable {
445 user = cfg.provision.localMail.recipientName;
446 password = "${pkgs.writeText "imap-password" "@imap-password@"}";
452 systemd.services.parsedmarc =
454 # Remove any empty attributes from the config, i.e. empty
455 # lists, empty attrsets and null. This makes it possible to
456 # list interesting options in `settings` without them always
457 # ending up in the resulting config.
458 filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [ null [] {} ])) cfg.settings;
460 # Extract secrets (attributes set to an attrset with a
461 # "_secret" key) from the settings and generate the commands
462 # to run to perform the secret replacements.
463 secretPaths = lib.catAttrs "_secret" (lib.collect isSecret filteredConfig);
464 parsedmarcConfig = ini.generate "parsedmarc.ini" filteredConfig;
465 mkSecretReplacement = file: ''
466 replace-secret ${lib.escapeShellArgs [ (hashString "sha256" file) file "/run/parsedmarc/parsedmarc.ini" ]}
468 secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
471 wantedBy = [ "multi-user.target" ];
472 after = [ "postfix.service" "dovecot2.service" "elasticsearch.service" ];
473 path = with pkgs; [ replace-secret openssl shadow ];
476 startPreFullPrivileges = ''
477 set -o errexit -o pipefail -o nounset -o errtrace
478 shopt -s inherit_errexit
481 cp ${parsedmarcConfig} /run/parsedmarc/parsedmarc.ini
482 chown parsedmarc:parsedmarc /run/parsedmarc/parsedmarc.ini
483 ${secretReplacements}
484 '' + lib.optionalString cfg.provision.localMail.enable ''
485 openssl rand -hex 64 >/run/parsedmarc/dmarc_user_passwd
486 replace-secret '@imap-password@' '/run/parsedmarc/dmarc_user_passwd' /run/parsedmarc/parsedmarc.ini
487 echo "Setting new randomized password for user '${cfg.provision.localMail.recipientName}'."
488 cat <(echo -n "${cfg.provision.localMail.recipientName}:") /run/parsedmarc/dmarc_user_passwd | chpasswd
491 "+${pkgs.writeShellScript "parsedmarc-start-pre-full-privileges" startPreFullPrivileges}";
494 Group = "parsedmarc";
496 RuntimeDirectory = "parsedmarc";
497 RuntimeDirectoryMode = "0700";
498 CapabilityBoundingSet = "";
499 PrivateDevices = true;
500 PrivateMounts = true;
503 ProtectControlGroups = true;
505 ProtectHostname = true;
506 ProtectKernelLogs = true;
507 ProtectKernelModules = true;
508 ProtectKernelTunables = true;
509 ProtectProc = "invisible";
511 SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
512 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
513 RestrictRealtime = true;
514 RestrictNamespaces = true;
515 MemoryDenyWriteExecute = true;
516 LockPersonality = true;
517 SystemCallArchitectures = "native";
518 ExecStart = "${pkgs.python3Packages.parsedmarc}/bin/parsedmarc -c /run/parsedmarc/parsedmarc.ini";
522 users.users.${cfg.provision.localMail.recipientName} = lib.mkIf cfg.provision.localMail.enable {
524 description = "DMARC mail recipient";
528 # Don't edit the docbook xml directly, edit the md and generate it:
529 # `pandoc parsedmarc.md -t docbook --top-level-division=chapter --extract-media=media -f markdown+smart > parsedmarc.xml`
530 meta.doc = ./parsedmarc.xml;
531 meta.maintainers = [ lib.maintainers.talyz ];