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