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 watch = true;
127 };
128 splunk_hec = {
129 url = "https://splunkhec.example.com";
130 token = { _secret = "/run/keys/splunk_token" };
131 index = "email";
132 };
133 }
134 '';
135 description = lib.mdDoc ''
136 Configuration parameters to set in
137 {file}`parsedmarc.ini`. For a full list of
138 available parameters, see
139 <https://domainaware.github.io/parsedmarc/#configuration-file>.
140
141 Settings containing secret data should be set to an attribute
142 set containing the attribute `_secret` - a
143 string pointing to a file containing the value the option
144 should be set to. See the example to get a better picture of
145 this: in the resulting {file}`parsedmarc.ini`
146 file, the `splunk_hec.token` key will be set
147 to the contents of the
148 {file}`/run/keys/splunk_token` file.
149 '';
150
151 type = lib.types.submodule {
152 freeformType = ini.type;
153
154 options = {
155 general = {
156 save_aggregate = lib.mkOption {
157 type = lib.types.bool;
158 default = true;
159 description = lib.mdDoc ''
160 Save aggregate report data to Elasticsearch and/or Splunk.
161 '';
162 };
163
164 save_forensic = lib.mkOption {
165 type = lib.types.bool;
166 default = true;
167 description = lib.mdDoc ''
168 Save forensic report data to Elasticsearch and/or Splunk.
169 '';
170 };
171 };
172
173 imap = {
174 host = lib.mkOption {
175 type = lib.types.str;
176 default = "localhost";
177 description = lib.mdDoc ''
178 The IMAP server hostname or IP address.
179 '';
180 };
181
182 port = lib.mkOption {
183 type = lib.types.port;
184 default = 993;
185 description = lib.mdDoc ''
186 The IMAP server port.
187 '';
188 };
189
190 ssl = lib.mkOption {
191 type = lib.types.bool;
192 default = true;
193 description = lib.mdDoc ''
194 Use an encrypted SSL/TLS connection.
195 '';
196 };
197
198 user = lib.mkOption {
199 type = with lib.types; nullOr str;
200 default = null;
201 description = lib.mdDoc ''
202 The IMAP server username.
203 '';
204 };
205
206 password = lib.mkOption {
207 type = with lib.types; nullOr (either path (attrsOf path));
208 default = null;
209 description = lib.mdDoc ''
210 The IMAP server password.
211
212 Always handled as a secret whether the value is
213 wrapped in a `{ _secret = ...; }`
214 attrset or not (refer to [](#opt-services.parsedmarc.settings) for
215 details).
216 '';
217 apply = x: if isAttrs x || x == null then x else { _secret = x; };
218 };
219
220 watch = lib.mkOption {
221 type = lib.types.bool;
222 default = true;
223 description = lib.mdDoc ''
224 Use the IMAP IDLE command to process messages as they arrive.
225 '';
226 };
227
228 delete = lib.mkOption {
229 type = lib.types.bool;
230 default = false;
231 description = lib.mdDoc ''
232 Delete messages after processing them, instead of archiving them.
233 '';
234 };
235 };
236
237 smtp = {
238 host = lib.mkOption {
239 type = with lib.types; nullOr str;
240 default = null;
241 description = lib.mdDoc ''
242 The SMTP server hostname or IP address.
243 '';
244 };
245
246 port = lib.mkOption {
247 type = with lib.types; nullOr port;
248 default = null;
249 description = lib.mdDoc ''
250 The SMTP server port.
251 '';
252 };
253
254 ssl = lib.mkOption {
255 type = with lib.types; nullOr bool;
256 default = null;
257 description = lib.mdDoc ''
258 Use an encrypted SSL/TLS connection.
259 '';
260 };
261
262 user = lib.mkOption {
263 type = with lib.types; nullOr str;
264 default = null;
265 description = lib.mdDoc ''
266 The SMTP server username.
267 '';
268 };
269
270 password = lib.mkOption {
271 type = with lib.types; nullOr (either path (attrsOf path));
272 default = null;
273 description = lib.mdDoc ''
274 The SMTP server password.
275
276 Always handled as a secret whether the value is
277 wrapped in a `{ _secret = ...; }`
278 attrset or not (refer to [](#opt-services.parsedmarc.settings) for
279 details).
280 '';
281 apply = x: if isAttrs x || x == null then x else { _secret = x; };
282 };
283
284 from = lib.mkOption {
285 type = with lib.types; nullOr str;
286 default = null;
287 description = lib.mdDoc ''
288 The `From` address to use for the
289 outgoing mail.
290 '';
291 };
292
293 to = lib.mkOption {
294 type = with lib.types; nullOr (listOf str);
295 default = null;
296 description = lib.mdDoc ''
297 The addresses to send outgoing mail to.
298 '';
299 };
300 };
301
302 elasticsearch = {
303 hosts = lib.mkOption {
304 default = [];
305 type = with lib.types; listOf str;
306 apply = x: if x == [] then null else lib.concatStringsSep "," x;
307 description = lib.mdDoc ''
308 A list of Elasticsearch hosts to push parsed reports
309 to.
310 '';
311 };
312
313 user = lib.mkOption {
314 type = with lib.types; nullOr str;
315 default = null;
316 description = lib.mdDoc ''
317 Username to use when connecting to Elasticsearch, if
318 required.
319 '';
320 };
321
322 password = lib.mkOption {
323 type = with lib.types; nullOr (either path (attrsOf path));
324 default = null;
325 description = lib.mdDoc ''
326 The password to use when connecting to Elasticsearch,
327 if required.
328
329 Always handled as a secret whether the value is
330 wrapped in a `{ _secret = ...; }`
331 attrset or not (refer to [](#opt-services.parsedmarc.settings) for
332 details).
333 '';
334 apply = x: if isAttrs x || x == null then x else { _secret = x; };
335 };
336
337 ssl = lib.mkOption {
338 type = lib.types.bool;
339 default = false;
340 description = lib.mdDoc ''
341 Whether to use an encrypted SSL/TLS connection.
342 '';
343 };
344
345 cert_path = lib.mkOption {
346 type = lib.types.path;
347 default = "/etc/ssl/certs/ca-certificates.crt";
348 description = lib.mdDoc ''
349 The path to a TLS certificate bundle used to verify
350 the server's certificate.
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 esVersion = lib.getVersion config.services.elasticsearch.package;
403 in
404 lib.mkIf cfg.provision.grafana.datasource [
405 {
406 name = "dmarc-ag";
407 type = "elasticsearch";
408 access = "proxy";
409 url = "http://localhost:9200";
410 jsonData = {
411 timeField = "date_range";
412 inherit esVersion;
413 };
414 }
415 {
416 name = "dmarc-fo";
417 type = "elasticsearch";
418 access = "proxy";
419 url = "http://localhost:9200";
420 jsonData = {
421 timeField = "date_range";
422 inherit esVersion;
423 };
424 }
425 ];
426 dashboards = lib.mkIf cfg.provision.grafana.dashboard [{
427 name = "parsedmarc";
428 options.path = "${pkgs.python3Packages.parsedmarc.dashboard}";
429 }];
430 };
431 };
432
433 services.parsedmarc.settings = lib.mkMerge [
434 (lib.mkIf cfg.provision.elasticsearch {
435 elasticsearch = {
436 hosts = [ "localhost:9200" ];
437 ssl = false;
438 };
439 })
440 (lib.mkIf cfg.provision.localMail.enable {
441 imap = {
442 host = "localhost";
443 port = 143;
444 ssl = false;
445 user = cfg.provision.localMail.recipientName;
446 password = "${pkgs.writeText "imap-password" "@imap-password@"}";
447 watch = true;
448 };
449 })
450 ];
451
452 systemd.services.parsedmarc =
453 let
454 # Remove any empty attributes from the config, i.e. empty
455 # lists, empty attrsets and null. This makes it possible to
456 # list interesting options in `settings` without them always
457 # ending up in the resulting config.
458 filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [ null [] {} ])) cfg.settings;
459
460 # Extract secrets (attributes set to an attrset with a
461 # "_secret" key) from the settings and generate the commands
462 # to run to perform the secret replacements.
463 secretPaths = lib.catAttrs "_secret" (lib.collect isSecret filteredConfig);
464 parsedmarcConfig = ini.generate "parsedmarc.ini" filteredConfig;
465 mkSecretReplacement = file: ''
466 replace-secret ${lib.escapeShellArgs [ (hashString "sha256" file) file "/run/parsedmarc/parsedmarc.ini" ]}
467 '';
468 secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
469 in
470 {
471 wantedBy = [ "multi-user.target" ];
472 after = [ "postfix.service" "dovecot2.service" "elasticsearch.service" ];
473 path = with pkgs; [ replace-secret openssl shadow ];
474 serviceConfig = {
475 ExecStartPre = let
476 startPreFullPrivileges = ''
477 set -o errexit -o pipefail -o nounset -o errtrace
478 shopt -s inherit_errexit
479
480 umask u=rwx,g=,o=
481 cp ${parsedmarcConfig} /run/parsedmarc/parsedmarc.ini
482 chown parsedmarc:parsedmarc /run/parsedmarc/parsedmarc.ini
483 ${secretReplacements}
484 '' + lib.optionalString cfg.provision.localMail.enable ''
485 openssl rand -hex 64 >/run/parsedmarc/dmarc_user_passwd
486 replace-secret '@imap-password@' '/run/parsedmarc/dmarc_user_passwd' /run/parsedmarc/parsedmarc.ini
487 echo "Setting new randomized password for user '${cfg.provision.localMail.recipientName}'."
488 cat <(echo -n "${cfg.provision.localMail.recipientName}:") /run/parsedmarc/dmarc_user_passwd | chpasswd
489 '';
490 in
491 "+${pkgs.writeShellScript "parsedmarc-start-pre-full-privileges" startPreFullPrivileges}";
492 Type = "simple";
493 User = "parsedmarc";
494 Group = "parsedmarc";
495 DynamicUser = true;
496 RuntimeDirectory = "parsedmarc";
497 RuntimeDirectoryMode = "0700";
498 CapabilityBoundingSet = "";
499 PrivateDevices = true;
500 PrivateMounts = true;
501 PrivateUsers = true;
502 ProtectClock = true;
503 ProtectControlGroups = true;
504 ProtectHome = true;
505 ProtectHostname = true;
506 ProtectKernelLogs = true;
507 ProtectKernelModules = true;
508 ProtectKernelTunables = true;
509 ProtectProc = "invisible";
510 ProcSubset = "pid";
511 SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
512 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
513 RestrictRealtime = true;
514 RestrictNamespaces = true;
515 MemoryDenyWriteExecute = true;
516 LockPersonality = true;
517 SystemCallArchitectures = "native";
518 ExecStart = "${pkgs.python3Packages.parsedmarc}/bin/parsedmarc -c /run/parsedmarc/parsedmarc.ini";
519 };
520 };
521
522 users.users.${cfg.provision.localMail.recipientName} = lib.mkIf cfg.provision.localMail.enable {
523 isNormalUser = true;
524 description = "DMARC mail recipient";
525 };
526 };
527
528 # Don't edit the docbook xml directly, edit the md and generate it:
529 # `pandoc parsedmarc.md -t docbook --top-level-division=chapter --extract-media=media -f markdown+smart > parsedmarc.xml`
530 meta.doc = ./parsedmarc.xml;
531 meta.maintainers = [ lib.maintainers.talyz ];
532}