1{
2 config,
3 lib,
4 options,
5 pkgs,
6 ...
7}:
8
9let
10 cfg = config.services.parsedmarc;
11 opt = options.services.parsedmarc;
12 isSecret = v: isAttrs v && v ? _secret && isString v._secret;
13 ini = pkgs.formats.ini {
14 mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" rec {
15 mkValueString =
16 v:
17 if isInt v then
18 toString v
19 else if isString v then
20 v
21 else if true == v then
22 "True"
23 else if false == v then
24 "False"
25 else if isSecret v then
26 hashString "sha256" v._secret
27 else
28 throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty { }) v}";
29 };
30 };
31 inherit (builtins)
32 elem
33 isAttrs
34 isString
35 isInt
36 isList
37 typeOf
38 hashString
39 ;
40in
41{
42 options.services.parsedmarc = {
43
44 enable = lib.mkEnableOption ''
45 parsedmarc, a DMARC report monitoring service
46 '';
47
48 provision = {
49 localMail = {
50 enable = lib.mkOption {
51 type = lib.types.bool;
52 default = false;
53 description = ''
54 Whether Postfix and Dovecot should be set up to receive
55 mail locally. parsedmarc will be configured to watch the
56 local inbox as the automatically created user specified in
57 [](#opt-services.parsedmarc.provision.localMail.recipientName)
58 '';
59 };
60
61 recipientName = lib.mkOption {
62 type = lib.types.str;
63 default = "dmarc";
64 description = ''
65 The DMARC mail recipient name, i.e. the name part of the
66 email address which receives DMARC reports.
67
68 A local user with this name will be set up and assigned a
69 randomized password on service start.
70 '';
71 };
72
73 hostname = lib.mkOption {
74 type = lib.types.str;
75 default = config.networking.fqdn;
76 defaultText = lib.literalExpression "config.networking.fqdn";
77 example = "monitoring.example.com";
78 description = ''
79 The hostname to use when configuring Postfix.
80
81 Should correspond to the host's fully qualified domain
82 name and the domain part of the email address which
83 receives DMARC reports. You also have to set up an MX record
84 pointing to this domain name.
85 '';
86 };
87 };
88
89 geoIp = lib.mkOption {
90 type = lib.types.bool;
91 default = true;
92 description = ''
93 Whether to enable and configure the [geoipupdate](#opt-services.geoipupdate.enable)
94 service to automatically fetch GeoIP databases. Not crucial,
95 but recommended for full functionality.
96
97 To finish the setup, you need to manually set the [](#opt-services.geoipupdate.settings.AccountID) and
98 [](#opt-services.geoipupdate.settings.LicenseKey)
99 options.
100 '';
101 };
102
103 elasticsearch = lib.mkOption {
104 type = lib.types.bool;
105 default = true;
106 description = ''
107 Whether to set up and use a local instance of Elasticsearch.
108 '';
109 };
110
111 grafana = {
112 datasource = lib.mkOption {
113 type = lib.types.bool;
114 default = cfg.provision.elasticsearch && config.services.grafana.enable;
115 defaultText = lib.literalExpression ''
116 config.${opt.provision.elasticsearch} && config.${options.services.grafana.enable}
117 '';
118 apply = x: x && cfg.provision.elasticsearch;
119 description = ''
120 Whether the automatically provisioned Elasticsearch
121 instance should be added as a grafana datasource. Has no
122 effect unless
123 [](#opt-services.parsedmarc.provision.elasticsearch)
124 is also enabled.
125 '';
126 };
127
128 dashboard = lib.mkOption {
129 type = lib.types.bool;
130 default = config.services.grafana.enable;
131 defaultText = lib.literalExpression "config.services.grafana.enable";
132 description = ''
133 Whether the official parsedmarc grafana dashboard should
134 be provisioned to the local grafana instance.
135 '';
136 };
137 };
138 };
139
140 settings = lib.mkOption {
141 example = lib.literalExpression ''
142 {
143 imap = {
144 host = "imap.example.com";
145 user = "alice@example.com";
146 password = { _secret = "/run/keys/imap_password" };
147 };
148 mailbox = {
149 watch = true;
150 batch_size = 30;
151 };
152 splunk_hec = {
153 url = "https://splunkhec.example.com";
154 token = { _secret = "/run/keys/splunk_token" };
155 index = "email";
156 };
157 }
158 '';
159 description = ''
160 Configuration parameters to set in
161 {file}`parsedmarc.ini`. For a full list of
162 available parameters, see
163 <https://domainaware.github.io/parsedmarc/#configuration-file>.
164
165 Settings containing secret data should be set to an attribute
166 set containing the attribute `_secret` - a
167 string pointing to a file containing the value the option
168 should be set to. See the example to get a better picture of
169 this: in the resulting {file}`parsedmarc.ini`
170 file, the `splunk_hec.token` key will be set
171 to the contents of the
172 {file}`/run/keys/splunk_token` file.
173 '';
174
175 type = lib.types.submodule {
176 freeformType = ini.type;
177
178 options = {
179 general = {
180 save_aggregate = lib.mkOption {
181 type = lib.types.bool;
182 default = true;
183 description = ''
184 Save aggregate report data to Elasticsearch and/or Splunk.
185 '';
186 };
187
188 save_forensic = lib.mkOption {
189 type = lib.types.bool;
190 default = true;
191 description = ''
192 Save forensic report data to Elasticsearch and/or Splunk.
193 '';
194 };
195 };
196
197 mailbox = {
198 watch = lib.mkOption {
199 type = lib.types.bool;
200 default = true;
201 description = ''
202 Use the IMAP IDLE command to process messages as they arrive.
203 '';
204 };
205
206 delete = lib.mkOption {
207 type = lib.types.bool;
208 default = false;
209 description = ''
210 Delete messages after processing them, instead of archiving them.
211 '';
212 };
213 };
214
215 imap = {
216 host = lib.mkOption {
217 type = lib.types.str;
218 default = "localhost";
219 description = ''
220 The IMAP server hostname or IP address.
221 '';
222 };
223
224 port = lib.mkOption {
225 type = lib.types.port;
226 default = 993;
227 description = ''
228 The IMAP server port.
229 '';
230 };
231
232 ssl = lib.mkOption {
233 type = lib.types.bool;
234 default = true;
235 description = ''
236 Use an encrypted SSL/TLS connection.
237 '';
238 };
239
240 user = lib.mkOption {
241 type = with lib.types; nullOr str;
242 default = null;
243 description = ''
244 The IMAP server username.
245 '';
246 };
247
248 password = lib.mkOption {
249 type = with lib.types; nullOr (either path (attrsOf path));
250 default = null;
251 description = ''
252 The IMAP server password.
253
254 Always handled as a secret whether the value is
255 wrapped in a `{ _secret = ...; }`
256 attrset or not (refer to [](#opt-services.parsedmarc.settings) for
257 details).
258 '';
259 apply = x: if isAttrs x || x == null then x else { _secret = x; };
260 };
261 };
262
263 smtp = {
264 host = lib.mkOption {
265 type = with lib.types; nullOr str;
266 default = null;
267 description = ''
268 The SMTP server hostname or IP address.
269 '';
270 };
271
272 port = lib.mkOption {
273 type = with lib.types; nullOr port;
274 default = null;
275 description = ''
276 The SMTP server port.
277 '';
278 };
279
280 ssl = lib.mkOption {
281 type = with lib.types; nullOr bool;
282 default = null;
283 description = ''
284 Use an encrypted SSL/TLS connection.
285 '';
286 };
287
288 user = lib.mkOption {
289 type = with lib.types; nullOr str;
290 default = null;
291 description = ''
292 The SMTP server username.
293 '';
294 };
295
296 password = lib.mkOption {
297 type = with lib.types; nullOr (either path (attrsOf path));
298 default = null;
299 description = ''
300 The SMTP server password.
301
302 Always handled as a secret whether the value is
303 wrapped in a `{ _secret = ...; }`
304 attrset or not (refer to [](#opt-services.parsedmarc.settings) for
305 details).
306 '';
307 apply = x: if isAttrs x || x == null then x else { _secret = x; };
308 };
309
310 from = lib.mkOption {
311 type = with lib.types; nullOr str;
312 default = null;
313 description = ''
314 The `From` address to use for the
315 outgoing mail.
316 '';
317 };
318
319 to = lib.mkOption {
320 type = with lib.types; nullOr (listOf str);
321 default = null;
322 description = ''
323 The addresses to send outgoing mail to.
324 '';
325 apply = x: if x == [ ] || x == null then null else lib.concatStringsSep "," x;
326 };
327 };
328
329 elasticsearch = {
330 hosts = lib.mkOption {
331 default = [ ];
332 type = with lib.types; listOf str;
333 apply = x: if x == [ ] then null else lib.concatStringsSep "," x;
334 description = ''
335 A list of Elasticsearch hosts to push parsed reports
336 to.
337 '';
338 };
339
340 user = lib.mkOption {
341 type = with lib.types; nullOr str;
342 default = null;
343 description = ''
344 Username to use when connecting to Elasticsearch, if
345 required.
346 '';
347 };
348
349 password = lib.mkOption {
350 type = with lib.types; nullOr (either path (attrsOf path));
351 default = null;
352 description = ''
353 The password to use when connecting to Elasticsearch,
354 if required.
355
356 Always handled as a secret whether the value is
357 wrapped in a `{ _secret = ...; }`
358 attrset or not (refer to [](#opt-services.parsedmarc.settings) for
359 details).
360 '';
361 apply = x: if isAttrs x || x == null then x else { _secret = x; };
362 };
363
364 ssl = lib.mkOption {
365 type = lib.types.bool;
366 default = false;
367 description = ''
368 Whether to use an encrypted SSL/TLS connection.
369 '';
370 };
371
372 cert_path = lib.mkOption {
373 type = lib.types.path;
374 default = config.security.pki.caBundle;
375 defaultText = lib.literalExpression "config.security.pki.caBundle";
376 description = ''
377 The path to a TLS certificate bundle used to verify
378 the server's certificate.
379 '';
380 };
381 };
382 };
383
384 };
385 };
386
387 };
388
389 config = lib.mkIf cfg.enable {
390
391 warnings =
392 let
393 deprecationWarning =
394 optname:
395 "Starting in 8.0.0, the `${optname}` option has been moved from the `services.parsedmarc.settings.imap`"
396 + "configuration section to the `services.parsedmarc.settings.mailbox` configuration section.";
397 hasImapOpt = lib.flip builtins.hasAttr cfg.settings.imap;
398 movedOptions = [
399 "reports_folder"
400 "archive_folder"
401 "watch"
402 "delete"
403 "test"
404 "batch_size"
405 ];
406 in
407 builtins.map deprecationWarning (builtins.filter hasImapOpt movedOptions);
408
409 services.elasticsearch.enable = lib.mkDefault cfg.provision.elasticsearch;
410
411 services.geoipupdate = lib.mkIf cfg.provision.geoIp {
412 enable = true;
413 settings = {
414 EditionIDs = [
415 "GeoLite2-ASN"
416 "GeoLite2-City"
417 "GeoLite2-Country"
418 ];
419 DatabaseDirectory = "/var/lib/GeoIP";
420 };
421 };
422
423 services.dovecot2 = lib.mkIf cfg.provision.localMail.enable {
424 enable = true;
425 protocols = [ "imap" ];
426 };
427
428 services.postfix = lib.mkIf cfg.provision.localMail.enable {
429 enable = true;
430 origin = cfg.provision.localMail.hostname;
431 config = {
432 myhostname = cfg.provision.localMail.hostname;
433 mydestination = cfg.provision.localMail.hostname;
434 };
435 };
436
437 services.grafana = {
438 declarativePlugins =
439 with pkgs.grafanaPlugins;
440 lib.mkIf cfg.provision.grafana.dashboard [
441 grafana-worldmap-panel
442 grafana-piechart-panel
443 ];
444
445 provision = {
446 enable = cfg.provision.grafana.datasource || cfg.provision.grafana.dashboard;
447 datasources.settings.datasources =
448 let
449 esVersion = lib.getVersion config.services.elasticsearch.package;
450 in
451 lib.mkIf cfg.provision.grafana.datasource [
452 {
453 name = "dmarc-ag";
454 type = "elasticsearch";
455 access = "proxy";
456 url = "http://localhost:9200";
457 jsonData = {
458 timeField = "date_range";
459 inherit esVersion;
460 };
461 }
462 {
463 name = "dmarc-fo";
464 type = "elasticsearch";
465 access = "proxy";
466 url = "http://localhost:9200";
467 jsonData = {
468 timeField = "date_range";
469 inherit esVersion;
470 };
471 }
472 ];
473 dashboards.settings.providers = lib.mkIf cfg.provision.grafana.dashboard [
474 {
475 name = "parsedmarc";
476 options.path = "${pkgs.parsedmarc.dashboard}";
477 }
478 ];
479 };
480 };
481
482 services.parsedmarc.settings = lib.mkMerge [
483 (lib.mkIf cfg.provision.elasticsearch {
484 elasticsearch = {
485 hosts = [ "http://localhost:9200" ];
486 ssl = false;
487 };
488 })
489 (lib.mkIf cfg.provision.localMail.enable {
490 imap = {
491 host = "localhost";
492 port = 143;
493 ssl = false;
494 user = cfg.provision.localMail.recipientName;
495 password = "${pkgs.writeText "imap-password" "@imap-password@"}";
496 };
497 mailbox = {
498 watch = true;
499 };
500 })
501 ];
502
503 systemd.services.parsedmarc =
504 let
505 # Remove any empty attributes from the config, i.e. empty
506 # lists, empty attrsets and null. This makes it possible to
507 # list interesting options in `settings` without them always
508 # ending up in the resulting config.
509 filteredConfig = lib.converge (lib.filterAttrsRecursive (
510 _: v:
511 !elem v [
512 null
513 [ ]
514 { }
515 ]
516 )) cfg.settings;
517
518 # Extract secrets (attributes set to an attrset with a
519 # "_secret" key) from the settings and generate the commands
520 # to run to perform the secret replacements.
521 secretPaths = lib.catAttrs "_secret" (lib.collect isSecret filteredConfig);
522 parsedmarcConfig = ini.generate "parsedmarc.ini" filteredConfig;
523 mkSecretReplacement = file: ''
524 replace-secret ${
525 lib.escapeShellArgs [
526 (hashString "sha256" file)
527 file
528 "/run/parsedmarc/parsedmarc.ini"
529 ]
530 }
531 '';
532 secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
533 in
534 {
535 wantedBy = [ "multi-user.target" ];
536 after = [
537 "postfix.service"
538 "dovecot2.service"
539 "elasticsearch.service"
540 ];
541 path = with pkgs; [
542 replace-secret
543 openssl
544 shadow
545 ];
546 serviceConfig = {
547 ExecStartPre =
548 let
549 startPreFullPrivileges =
550 ''
551 set -o errexit -o pipefail -o nounset -o errtrace
552 shopt -s inherit_errexit
553
554 umask u=rwx,g=,o=
555 cp ${parsedmarcConfig} /run/parsedmarc/parsedmarc.ini
556 chown parsedmarc:parsedmarc /run/parsedmarc/parsedmarc.ini
557 ${secretReplacements}
558 ''
559 + lib.optionalString cfg.provision.localMail.enable ''
560 openssl rand -hex 64 >/run/parsedmarc/dmarc_user_passwd
561 replace-secret '@imap-password@' '/run/parsedmarc/dmarc_user_passwd' /run/parsedmarc/parsedmarc.ini
562 echo "Setting new randomized password for user '${cfg.provision.localMail.recipientName}'."
563 cat <(echo -n "${cfg.provision.localMail.recipientName}:") /run/parsedmarc/dmarc_user_passwd | chpasswd
564 '';
565 in
566 "+${pkgs.writeShellScript "parsedmarc-start-pre-full-privileges" startPreFullPrivileges}";
567 Type = "simple";
568 User = "parsedmarc";
569 Group = "parsedmarc";
570 DynamicUser = true;
571 RuntimeDirectory = "parsedmarc";
572 RuntimeDirectoryMode = "0700";
573 CapabilityBoundingSet = "";
574 PrivateDevices = true;
575 PrivateMounts = true;
576 PrivateUsers = true;
577 ProtectClock = true;
578 ProtectControlGroups = true;
579 ProtectHome = true;
580 ProtectHostname = true;
581 ProtectKernelLogs = true;
582 ProtectKernelModules = true;
583 ProtectKernelTunables = true;
584 ProtectProc = "invisible";
585 ProcSubset = "pid";
586 SystemCallFilter = [
587 "@system-service"
588 "~@privileged"
589 "~@resources"
590 ];
591 RestrictAddressFamilies = [
592 "AF_UNIX"
593 "AF_INET"
594 "AF_INET6"
595 ];
596 RestrictRealtime = true;
597 RestrictNamespaces = true;
598 MemoryDenyWriteExecute = true;
599 LockPersonality = true;
600 SystemCallArchitectures = "native";
601 ExecStart = "${lib.getExe pkgs.parsedmarc} -c /run/parsedmarc/parsedmarc.ini";
602 };
603 };
604
605 users.users.${cfg.provision.localMail.recipientName} = lib.mkIf cfg.provision.localMail.enable {
606 isNormalUser = true;
607 description = "DMARC mail recipient";
608 };
609 };
610
611 meta.doc = ./parsedmarc.md;
612 meta.maintainers = [ lib.maintainers.talyz ];
613}