python3Packages.orjson: Disable failing tests on 32 bit
[NixPkgs.git] / nixos / modules / services / monitoring / parsedmarc.nix
blob3540d91fc9f371396748d11064d121c319280d05
1 { config, lib, options, pkgs, ... }:
3 let
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;
21   options.services.parsedmarc = {
23     enable = lib.mkEnableOption (lib.mdDoc ''
24       parsedmarc, a DMARC report monitoring service
25     '');
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         };
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.
47             A local user with this name will be set up and assigned a
48             randomized password on service start.
49           '';
50         };
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.
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       };
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.
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       };
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       };
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         };
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     };
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>.
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       '';
151       type = lib.types.submodule {
152         freeformType = ini.type;
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             };
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           };
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             };
182             port = lib.mkOption {
183               type = lib.types.port;
184               default = 993;
185               description = lib.mdDoc ''
186                 The IMAP server port.
187               '';
188             };
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             };
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             };
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.
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             };
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             };
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           };
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             };
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             };
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             };
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             };
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.
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             };
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             };
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           };
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             };
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             };
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.
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             };
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             };
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         };
356       };
357     };
359   };
361   config = lib.mkIf cfg.enable {
363     services.elasticsearch.enable = lib.mkDefault cfg.provision.elasticsearch;
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     };
377     services.dovecot2 = lib.mkIf cfg.provision.localMail.enable {
378       enable = true;
379       protocols = [ "imap" ];
380     };
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     };
391     services.grafana = {
392       declarativePlugins = with pkgs.grafanaPlugins;
393         lib.mkIf cfg.provision.grafana.dashboard [
394           grafana-worldmap-panel
395           grafana-piechart-panel
396         ];
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     };
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     ];
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;
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
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         };
522     users.users.${cfg.provision.localMail.recipientName} = lib.mkIf cfg.provision.localMail.enable {
523       isNormalUser = true;
524       description = "DMARC mail recipient";
525     };
526   };
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 ];