grafana-alloy: don't build the frontend twice
[NixPkgs.git] / nixos / modules / services / monitoring / parsedmarc.nix
bloba14ade59c29eb890483807df03137c801c8b4c65
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 ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
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           };
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>.
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       '';
154       type = lib.types.submodule {
155         freeformType = ini.type;
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             };
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           };
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             };
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           };
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             };
203             port = lib.mkOption {
204               type = lib.types.port;
205               default = 993;
206               description = ''
207                 The IMAP server port.
208               '';
209             };
211             ssl = lib.mkOption {
212               type = lib.types.bool;
213               default = true;
214               description = ''
215                 Use an encrypted SSL/TLS connection.
216               '';
217             };
219             user = lib.mkOption {
220               type = with lib.types; nullOr str;
221               default = null;
222               description = ''
223                 The IMAP server username.
224               '';
225             };
227             password = lib.mkOption {
228               type = with lib.types; nullOr (either path (attrsOf path));
229               default = null;
230               description = ''
231                 The IMAP server password.
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           };
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             };
251             port = lib.mkOption {
252               type = with lib.types; nullOr port;
253               default = null;
254               description = ''
255                 The SMTP server port.
256               '';
257             };
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             };
267             user = lib.mkOption {
268               type = with lib.types; nullOr str;
269               default = null;
270               description = ''
271                 The SMTP server username.
272               '';
273             };
275             password = lib.mkOption {
276               type = with lib.types; nullOr (either path (attrsOf path));
277               default = null;
278               description = ''
279                 The SMTP server password.
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             };
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             };
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           };
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             };
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             };
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.
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             };
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             };
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         };
362       };
363     };
365   };
367   config = lib.mkIf cfg.enable {
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);
376     services.elasticsearch.enable = lib.mkDefault cfg.provision.elasticsearch;
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     };
390     services.dovecot2 = lib.mkIf cfg.provision.localMail.enable {
391       enable = true;
392       protocols = [ "imap" ];
393     };
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     };
404     services.grafana = {
405       declarativePlugins = with pkgs.grafanaPlugins;
406         lib.mkIf cfg.provision.grafana.dashboard [
407           grafana-worldmap-panel
408           grafana-piechart-panel
409         ];
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     };
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     ];
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;
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
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         };
537     users.users.${cfg.provision.localMail.recipientName} = lib.mkIf cfg.provision.localMail.enable {
538       isNormalUser = true;
539       description = "DMARC mail recipient";
540     };
541   };
543   meta.doc = ./parsedmarc.md;
544   meta.maintainers = [ lib.maintainers.talyz ];