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 ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
207 The IMAP server port.
208 '';
209 };
210
211 ssl = lib.mkOption {
212 type = lib.types.bool;
213 default = true;
214 description = ''
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 = ''
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 = ''
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 = ''
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 = ''
255 The SMTP server port.
256 '';
257 };
258
259 ssl = lib.mkOption {
260 type = with lib.types; nullOr bool;
261 default = null;
262 description = ''
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 = ''
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 = ''
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 = ''
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 = ''
302 The addresses to send outgoing mail to.
303 '';
304 apply = x: if x == [] || x == null then null else lib.concatStringsSep "," x;
305 };
306 };
307
308 elasticsearch = {
309 hosts = lib.mkOption {
310 default = [];
311 type = with lib.types; listOf str;
312 apply = x: if x == [] then null else lib.concatStringsSep "," x;
313 description = ''
314 A list of Elasticsearch hosts to push parsed reports
315 to.
316 '';
317 };
318
319 user = lib.mkOption {
320 type = with lib.types; nullOr str;
321 default = null;
322 description = ''
323 Username to use when connecting to Elasticsearch, if
324 required.
325 '';
326 };
327
328 password = lib.mkOption {
329 type = with lib.types; nullOr (either path (attrsOf path));
330 default = null;
331 description = ''
332 The password to use when connecting to Elasticsearch,
333 if required.
334
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
338 details).
339 '';
340 apply = x: if isAttrs x || x == null then x else { _secret = x; };
341 };
342
343 ssl = lib.mkOption {
344 type = lib.types.bool;
345 default = false;
346 description = ''
347 Whether to use an encrypted SSL/TLS connection.
348 '';
349 };
350
351 cert_path = lib.mkOption {
352 type = lib.types.path;
353 default = "/etc/ssl/certs/ca-certificates.crt";
354 description = ''
355 The path to a TLS certificate bundle used to verify
356 the server's certificate.
357 '';
358 };
359 };
360 };
361
362 };
363 };
364
365 };
366
367 config = lib.mkIf cfg.enable {
368
369 warnings = let
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);
375
376 services.elasticsearch.enable = lib.mkDefault cfg.provision.elasticsearch;
377
378 services.geoipupdate = lib.mkIf cfg.provision.geoIp {
379 enable = true;
380 settings = {
381 EditionIDs = [
382 "GeoLite2-ASN"
383 "GeoLite2-City"
384 "GeoLite2-Country"
385 ];
386 DatabaseDirectory = "/var/lib/GeoIP";
387 };
388 };
389
390 services.dovecot2 = lib.mkIf cfg.provision.localMail.enable {
391 enable = true;
392 protocols = [ "imap" ];
393 };
394
395 services.postfix = lib.mkIf cfg.provision.localMail.enable {
396 enable = true;
397 origin = cfg.provision.localMail.hostname;
398 config = {
399 myhostname = cfg.provision.localMail.hostname;
400 mydestination = cfg.provision.localMail.hostname;
401 };
402 };
403
404 services.grafana = {
405 declarativePlugins = with pkgs.grafanaPlugins;
406 lib.mkIf cfg.provision.grafana.dashboard [
407 grafana-worldmap-panel
408 grafana-piechart-panel
409 ];
410
411 provision = {
412 enable = cfg.provision.grafana.datasource || cfg.provision.grafana.dashboard;
413 datasources.settings.datasources =
414 let
415 esVersion = lib.getVersion config.services.elasticsearch.package;
416 in
417 lib.mkIf cfg.provision.grafana.datasource [
418 {
419 name = "dmarc-ag";
420 type = "elasticsearch";
421 access = "proxy";
422 url = "http://localhost:9200";
423 jsonData = {
424 timeField = "date_range";
425 inherit esVersion;
426 };
427 }
428 {
429 name = "dmarc-fo";
430 type = "elasticsearch";
431 access = "proxy";
432 url = "http://localhost:9200";
433 jsonData = {
434 timeField = "date_range";
435 inherit esVersion;
436 };
437 }
438 ];
439 dashboards.settings.providers = lib.mkIf cfg.provision.grafana.dashboard [{
440 name = "parsedmarc";
441 options.path = "${pkgs.parsedmarc.dashboard}";
442 }];
443 };
444 };
445
446 services.parsedmarc.settings = lib.mkMerge [
447 (lib.mkIf cfg.provision.elasticsearch {
448 elasticsearch = {
449 hosts = [ "http://localhost:9200" ];
450 ssl = false;
451 };
452 })
453 (lib.mkIf cfg.provision.localMail.enable {
454 imap = {
455 host = "localhost";
456 port = 143;
457 ssl = false;
458 user = cfg.provision.localMail.recipientName;
459 password = "${pkgs.writeText "imap-password" "@imap-password@"}";
460 };
461 mailbox = {
462 watch = true;
463 };
464 })
465 ];
466
467 systemd.services.parsedmarc =
468 let
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;
474
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" ]}
482 '';
483 secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
484 in
485 {
486 wantedBy = [ "multi-user.target" ];
487 after = [ "postfix.service" "dovecot2.service" "elasticsearch.service" ];
488 path = with pkgs; [ replace-secret openssl shadow ];
489 serviceConfig = {
490 ExecStartPre = let
491 startPreFullPrivileges = ''
492 set -o errexit -o pipefail -o nounset -o errtrace
493 shopt -s inherit_errexit
494
495 umask u=rwx,g=,o=
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
504 '';
505 in
506 "+${pkgs.writeShellScript "parsedmarc-start-pre-full-privileges" startPreFullPrivileges}";
507 Type = "simple";
508 User = "parsedmarc";
509 Group = "parsedmarc";
510 DynamicUser = true;
511 RuntimeDirectory = "parsedmarc";
512 RuntimeDirectoryMode = "0700";
513 CapabilityBoundingSet = "";
514 PrivateDevices = true;
515 PrivateMounts = true;
516 PrivateUsers = true;
517 ProtectClock = true;
518 ProtectControlGroups = true;
519 ProtectHome = true;
520 ProtectHostname = true;
521 ProtectKernelLogs = true;
522 ProtectKernelModules = true;
523 ProtectKernelTunables = true;
524 ProtectProc = "invisible";
525 ProcSubset = "pid";
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";
534 };
535 };
536
537 users.users.${cfg.provision.localMail.recipientName} = lib.mkIf cfg.provision.localMail.enable {
538 isNormalUser = true;
539 description = "DMARC mail recipient";
540 };
541 };
542
543 meta.doc = ./parsedmarc.md;
544 meta.maintainers = [ lib.maintainers.talyz ];
545}