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