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 ''
24 parsedmarc, a DMARC report monitoring service
29 enable = lib.mkOption {
30 type = lib.types.bool;
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 {
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";
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;
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;
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;
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";
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" };
132 url = "https://splunkhec.example.com";
133 token = { _secret = "/run/keys/splunk_token" };
139 Configuration parameters to set in
140 {file}`parsedmarc.ini`. For a full list of
141 available parameters, see
142 <https://domainaware.github.io/parsedmarc/#configuration-file>.
144 Settings containing secret data should be set to an attribute
145 set containing the attribute `_secret` - a
146 string pointing to a file containing the value the option
147 should be set to. See the example to get a better picture of
148 this: in the resulting {file}`parsedmarc.ini`
149 file, the `splunk_hec.token` key will be set
150 to the contents of the
151 {file}`/run/keys/splunk_token` file.
154 type = lib.types.submodule {
155 freeformType = ini.type;
159 save_aggregate = lib.mkOption {
160 type = lib.types.bool;
163 Save aggregate report data to Elasticsearch and/or Splunk.
167 save_forensic = lib.mkOption {
168 type = lib.types.bool;
171 Save forensic report data to Elasticsearch and/or Splunk.
177 watch = lib.mkOption {
178 type = lib.types.bool;
181 Use the IMAP IDLE command to process messages as they arrive.
185 delete = lib.mkOption {
186 type = lib.types.bool;
189 Delete messages after processing them, instead of archiving them.
195 host = lib.mkOption {
196 type = lib.types.str;
197 default = "localhost";
199 The IMAP server hostname or IP address.
203 port = lib.mkOption {
204 type = lib.types.port;
207 The IMAP server port.
212 type = lib.types.bool;
215 Use an encrypted SSL/TLS connection.
219 user = lib.mkOption {
220 type = with lib.types; nullOr str;
223 The IMAP server username.
227 password = lib.mkOption {
228 type = with lib.types; nullOr (either path (attrsOf path));
231 The IMAP server password.
233 Always handled as a secret whether the value is
234 wrapped in a `{ _secret = ...; }`
235 attrset or not (refer to [](#opt-services.parsedmarc.settings) for
238 apply = x: if isAttrs x || x == null then x else { _secret = x; };
243 host = lib.mkOption {
244 type = with lib.types; nullOr str;
247 The SMTP server hostname or IP address.
251 port = lib.mkOption {
252 type = with lib.types; nullOr port;
255 The SMTP server port.
260 type = with lib.types; nullOr bool;
263 Use an encrypted SSL/TLS connection.
267 user = lib.mkOption {
268 type = with lib.types; nullOr str;
271 The SMTP server username.
275 password = lib.mkOption {
276 type = with lib.types; nullOr (either path (attrsOf path));
279 The SMTP server password.
281 Always handled as a secret whether the value is
282 wrapped in a `{ _secret = ...; }`
283 attrset or not (refer to [](#opt-services.parsedmarc.settings) for
286 apply = x: if isAttrs x || x == null then x else { _secret = x; };
289 from = lib.mkOption {
290 type = with lib.types; nullOr str;
293 The `From` address to use for the
299 type = with lib.types; nullOr (listOf str);
302 The addresses to send outgoing mail to.
304 apply = x: if x == [] || x == null then null else lib.concatStringsSep "," x;
309 hosts = lib.mkOption {
311 type = with lib.types; listOf str;
312 apply = x: if x == [] then null else lib.concatStringsSep "," x;
314 A list of Elasticsearch hosts to push parsed reports
319 user = lib.mkOption {
320 type = with lib.types; nullOr str;
323 Username to use when connecting to Elasticsearch, if
328 password = lib.mkOption {
329 type = with lib.types; nullOr (either path (attrsOf path));
332 The password to use when connecting to Elasticsearch,
335 Always handled as a secret whether the value is
336 wrapped in a `{ _secret = ...; }`
337 attrset or not (refer to [](#opt-services.parsedmarc.settings) for
340 apply = x: if isAttrs x || x == null then x else { _secret = x; };
344 type = lib.types.bool;
347 Whether to use an encrypted SSL/TLS connection.
351 cert_path = lib.mkOption {
352 type = lib.types.path;
353 default = "/etc/ssl/certs/ca-certificates.crt";
355 The path to a TLS certificate bundle used to verify
356 the server's certificate.
367 config = lib.mkIf cfg.enable {
370 deprecationWarning = optname: "Starting in 8.0.0, the `${optname}` option has been moved from the `services.parsedmarc.settings.imap`"
371 + "configuration section to the `services.parsedmarc.settings.mailbox` configuration section.";
372 hasImapOpt = lib.flip builtins.hasAttr cfg.settings.imap;
373 movedOptions = [ "reports_folder" "archive_folder" "watch" "delete" "test" "batch_size" ];
374 in builtins.map deprecationWarning (builtins.filter hasImapOpt movedOptions);
376 services.elasticsearch.enable = lib.mkDefault cfg.provision.elasticsearch;
378 services.geoipupdate = lib.mkIf cfg.provision.geoIp {
386 DatabaseDirectory = "/var/lib/GeoIP";
390 services.dovecot2 = lib.mkIf cfg.provision.localMail.enable {
392 protocols = [ "imap" ];
395 services.postfix = lib.mkIf cfg.provision.localMail.enable {
397 origin = cfg.provision.localMail.hostname;
399 myhostname = cfg.provision.localMail.hostname;
400 mydestination = cfg.provision.localMail.hostname;
405 declarativePlugins = with pkgs.grafanaPlugins;
406 lib.mkIf cfg.provision.grafana.dashboard [
407 grafana-worldmap-panel
408 grafana-piechart-panel
412 enable = cfg.provision.grafana.datasource || cfg.provision.grafana.dashboard;
413 datasources.settings.datasources =
415 esVersion = lib.getVersion config.services.elasticsearch.package;
417 lib.mkIf cfg.provision.grafana.datasource [
420 type = "elasticsearch";
422 url = "http://localhost:9200";
424 timeField = "date_range";
430 type = "elasticsearch";
432 url = "http://localhost:9200";
434 timeField = "date_range";
439 dashboards.settings.providers = lib.mkIf cfg.provision.grafana.dashboard [{
441 options.path = "${pkgs.parsedmarc.dashboard}";
446 services.parsedmarc.settings = lib.mkMerge [
447 (lib.mkIf cfg.provision.elasticsearch {
449 hosts = [ "http://localhost:9200" ];
453 (lib.mkIf cfg.provision.localMail.enable {
458 user = cfg.provision.localMail.recipientName;
459 password = "${pkgs.writeText "imap-password" "@imap-password@"}";
467 systemd.services.parsedmarc =
469 # Remove any empty attributes from the config, i.e. empty
470 # lists, empty attrsets and null. This makes it possible to
471 # list interesting options in `settings` without them always
472 # ending up in the resulting config.
473 filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [ null [] {} ])) cfg.settings;
475 # Extract secrets (attributes set to an attrset with a
476 # "_secret" key) from the settings and generate the commands
477 # to run to perform the secret replacements.
478 secretPaths = lib.catAttrs "_secret" (lib.collect isSecret filteredConfig);
479 parsedmarcConfig = ini.generate "parsedmarc.ini" filteredConfig;
480 mkSecretReplacement = file: ''
481 replace-secret ${lib.escapeShellArgs [ (hashString "sha256" file) file "/run/parsedmarc/parsedmarc.ini" ]}
483 secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
486 wantedBy = [ "multi-user.target" ];
487 after = [ "postfix.service" "dovecot2.service" "elasticsearch.service" ];
488 path = with pkgs; [ replace-secret openssl shadow ];
491 startPreFullPrivileges = ''
492 set -o errexit -o pipefail -o nounset -o errtrace
493 shopt -s inherit_errexit
496 cp ${parsedmarcConfig} /run/parsedmarc/parsedmarc.ini
497 chown parsedmarc:parsedmarc /run/parsedmarc/parsedmarc.ini
498 ${secretReplacements}
499 '' + lib.optionalString cfg.provision.localMail.enable ''
500 openssl rand -hex 64 >/run/parsedmarc/dmarc_user_passwd
501 replace-secret '@imap-password@' '/run/parsedmarc/dmarc_user_passwd' /run/parsedmarc/parsedmarc.ini
502 echo "Setting new randomized password for user '${cfg.provision.localMail.recipientName}'."
503 cat <(echo -n "${cfg.provision.localMail.recipientName}:") /run/parsedmarc/dmarc_user_passwd | chpasswd
506 "+${pkgs.writeShellScript "parsedmarc-start-pre-full-privileges" startPreFullPrivileges}";
509 Group = "parsedmarc";
511 RuntimeDirectory = "parsedmarc";
512 RuntimeDirectoryMode = "0700";
513 CapabilityBoundingSet = "";
514 PrivateDevices = true;
515 PrivateMounts = true;
518 ProtectControlGroups = true;
520 ProtectHostname = true;
521 ProtectKernelLogs = true;
522 ProtectKernelModules = true;
523 ProtectKernelTunables = true;
524 ProtectProc = "invisible";
526 SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
527 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
528 RestrictRealtime = true;
529 RestrictNamespaces = true;
530 MemoryDenyWriteExecute = true;
531 LockPersonality = true;
532 SystemCallArchitectures = "native";
533 ExecStart = "${lib.getExe pkgs.parsedmarc} -c /run/parsedmarc/parsedmarc.ini";
537 users.users.${cfg.provision.localMail.recipientName} = lib.mkIf cfg.provision.localMail.enable {
539 description = "DMARC mail recipient";
543 meta.doc = ./parsedmarc.md;
544 meta.maintainers = [ lib.maintainers.talyz ];