{ config, lib, options, pkgs, ... }:

let
  cfg = config.services.parsedmarc;
  opt = options.services.parsedmarc;
  isSecret = v: isAttrs v && v ? _secret && isString v._secret;
  ini = pkgs.formats.ini {
    mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" rec {
      mkValueString = v:
        if isInt           v then toString v
        else if isString   v then v
        else if true  ==   v then "True"
        else if false ==   v then "False"
        else if isSecret   v then hashString "sha256" v._secret
        else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
    };
  };
  inherit (builtins) elem isAttrs isString isInt isList typeOf hashString;
in
{
  options.services.parsedmarc = {

    enable = lib.mkEnableOption (lib.mdDoc ''
      parsedmarc, a DMARC report monitoring service
    '');

    provision = {
      localMail = {
        enable = lib.mkOption {
          type = lib.types.bool;
          default = false;
          description = lib.mdDoc ''
            Whether Postfix and Dovecot should be set up to receive
            mail locally. parsedmarc will be configured to watch the
            local inbox as the automatically created user specified in
            [](#opt-services.parsedmarc.provision.localMail.recipientName)
          '';
        };

        recipientName = lib.mkOption {
          type = lib.types.str;
          default = "dmarc";
          description = lib.mdDoc ''
            The DMARC mail recipient name, i.e. the name part of the
            email address which receives DMARC reports.

            A local user with this name will be set up and assigned a
            randomized password on service start.
          '';
        };

        hostname = lib.mkOption {
          type = lib.types.str;
          default = config.networking.fqdn;
          defaultText = lib.literalExpression "config.networking.fqdn";
          example = "monitoring.example.com";
          description = lib.mdDoc ''
            The hostname to use when configuring Postfix.

            Should correspond to the host's fully qualified domain
            name and the domain part of the email address which
            receives DMARC reports. You also have to set up an MX record
            pointing to this domain name.
          '';
        };
      };

      geoIp = lib.mkOption {
        type = lib.types.bool;
        default = true;
        description = lib.mdDoc ''
          Whether to enable and configure the [geoipupdate](#opt-services.geoipupdate.enable)
          service to automatically fetch GeoIP databases. Not crucial,
          but recommended for full functionality.

          To finish the setup, you need to manually set the [](#opt-services.geoipupdate.settings.AccountID) and
          [](#opt-services.geoipupdate.settings.LicenseKey)
          options.
        '';
      };

      elasticsearch = lib.mkOption {
        type = lib.types.bool;
        default = true;
        description = lib.mdDoc ''
          Whether to set up and use a local instance of Elasticsearch.
        '';
      };

      grafana = {
        datasource = lib.mkOption {
          type = lib.types.bool;
          default = cfg.provision.elasticsearch && config.services.grafana.enable;
          defaultText = lib.literalExpression ''
            config.${opt.provision.elasticsearch} && config.${options.services.grafana.enable}
          '';
          apply = x: x && cfg.provision.elasticsearch;
          description = lib.mdDoc ''
            Whether the automatically provisioned Elasticsearch
            instance should be added as a grafana datasource. Has no
            effect unless
            [](#opt-services.parsedmarc.provision.elasticsearch)
            is also enabled.
          '';
        };

        dashboard = lib.mkOption {
          type = lib.types.bool;
          default = config.services.grafana.enable;
          defaultText = lib.literalExpression "config.services.grafana.enable";
          description = lib.mdDoc ''
            Whether the official parsedmarc grafana dashboard should
            be provisioned to the local grafana instance.
          '';
        };
      };
    };

    settings = lib.mkOption {
      example = lib.literalExpression ''
        {
          imap = {
            host = "imap.example.com";
            user = "alice@example.com";
            password = { _secret = "/run/keys/imap_password" };
            watch = true;
          };
          splunk_hec = {
            url = "https://splunkhec.example.com";
            token = { _secret = "/run/keys/splunk_token" };
            index = "email";
          };
        }
      '';
      description = lib.mdDoc ''
        Configuration parameters to set in
        {file}`parsedmarc.ini`. For a full list of
        available parameters, see
        <https://domainaware.github.io/parsedmarc/#configuration-file>.

        Settings containing secret data should be set to an attribute
        set containing the attribute `_secret` - a
        string pointing to a file containing the value the option
        should be set to. See the example to get a better picture of
        this: in the resulting {file}`parsedmarc.ini`
        file, the `splunk_hec.token` key will be set
        to the contents of the
        {file}`/run/keys/splunk_token` file.
      '';

      type = lib.types.submodule {
        freeformType = ini.type;

        options = {
          general = {
            save_aggregate = lib.mkOption {
              type = lib.types.bool;
              default = true;
              description = lib.mdDoc ''
                Save aggregate report data to Elasticsearch and/or Splunk.
              '';
            };

            save_forensic = lib.mkOption {
              type = lib.types.bool;
              default = true;
              description = lib.mdDoc ''
                Save forensic report data to Elasticsearch and/or Splunk.
              '';
            };
          };

          imap = {
            host = lib.mkOption {
              type = lib.types.str;
              default = "localhost";
              description = lib.mdDoc ''
                The IMAP server hostname or IP address.
              '';
            };

            port = lib.mkOption {
              type = lib.types.port;
              default = 993;
              description = lib.mdDoc ''
                The IMAP server port.
              '';
            };

            ssl = lib.mkOption {
              type = lib.types.bool;
              default = true;
              description = lib.mdDoc ''
                Use an encrypted SSL/TLS connection.
              '';
            };

            user = lib.mkOption {
              type = with lib.types; nullOr str;
              default = null;
              description = lib.mdDoc ''
                The IMAP server username.
              '';
            };

            password = lib.mkOption {
              type = with lib.types; nullOr (either path (attrsOf path));
              default = null;
              description = lib.mdDoc ''
                The IMAP server password.

                Always handled as a secret whether the value is
                wrapped in a `{ _secret = ...; }`
                attrset or not (refer to [](#opt-services.parsedmarc.settings) for
                details).
              '';
              apply = x: if isAttrs x || x == null then x else { _secret = x; };
            };

            watch = lib.mkOption {
              type = lib.types.bool;
              default = true;
              description = lib.mdDoc ''
                Use the IMAP IDLE command to process messages as they arrive.
              '';
            };

            delete = lib.mkOption {
              type = lib.types.bool;
              default = false;
              description = lib.mdDoc ''
                Delete messages after processing them, instead of archiving them.
              '';
            };
          };

          smtp = {
            host = lib.mkOption {
              type = with lib.types; nullOr str;
              default = null;
              description = lib.mdDoc ''
                The SMTP server hostname or IP address.
              '';
            };

            port = lib.mkOption {
              type = with lib.types; nullOr port;
              default = null;
              description = lib.mdDoc ''
                The SMTP server port.
              '';
            };

            ssl = lib.mkOption {
              type = with lib.types; nullOr bool;
              default = null;
              description = lib.mdDoc ''
                Use an encrypted SSL/TLS connection.
              '';
            };

            user = lib.mkOption {
              type = with lib.types; nullOr str;
              default = null;
              description = lib.mdDoc ''
                The SMTP server username.
              '';
            };

            password = lib.mkOption {
              type = with lib.types; nullOr (either path (attrsOf path));
              default = null;
              description = lib.mdDoc ''
                The SMTP server password.

                Always handled as a secret whether the value is
                wrapped in a `{ _secret = ...; }`
                attrset or not (refer to [](#opt-services.parsedmarc.settings) for
                details).
              '';
              apply = x: if isAttrs x || x == null then x else { _secret = x; };
            };

            from = lib.mkOption {
              type = with lib.types; nullOr str;
              default = null;
              description = lib.mdDoc ''
                The `From` address to use for the
                outgoing mail.
              '';
            };

            to = lib.mkOption {
              type = with lib.types; nullOr (listOf str);
              default = null;
              description = lib.mdDoc ''
                The addresses to send outgoing mail to.
              '';
            };
          };

          elasticsearch = {
            hosts = lib.mkOption {
              default = [];
              type = with lib.types; listOf str;
              apply = x: if x == [] then null else lib.concatStringsSep "," x;
              description = lib.mdDoc ''
                A list of Elasticsearch hosts to push parsed reports
                to.
              '';
            };

            user = lib.mkOption {
              type = with lib.types; nullOr str;
              default = null;
              description = lib.mdDoc ''
                Username to use when connecting to Elasticsearch, if
                required.
              '';
            };

            password = lib.mkOption {
              type = with lib.types; nullOr (either path (attrsOf path));
              default = null;
              description = lib.mdDoc ''
                The password to use when connecting to Elasticsearch,
                if required.

                Always handled as a secret whether the value is
                wrapped in a `{ _secret = ...; }`
                attrset or not (refer to [](#opt-services.parsedmarc.settings) for
                details).
              '';
              apply = x: if isAttrs x || x == null then x else { _secret = x; };
            };

            ssl = lib.mkOption {
              type = lib.types.bool;
              default = false;
              description = lib.mdDoc ''
                Whether to use an encrypted SSL/TLS connection.
              '';
            };

            cert_path = lib.mkOption {
              type = lib.types.path;
              default = "/etc/ssl/certs/ca-certificates.crt";
              description = lib.mdDoc ''
                The path to a TLS certificate bundle used to verify
                the server's certificate.
              '';
            };
          };
        };

      };
    };

  };

  config = lib.mkIf cfg.enable {

    services.elasticsearch.enable = lib.mkDefault cfg.provision.elasticsearch;

    services.geoipupdate = lib.mkIf cfg.provision.geoIp {
      enable = true;
      settings = {
        EditionIDs = [
          "GeoLite2-ASN"
          "GeoLite2-City"
          "GeoLite2-Country"
        ];
        DatabaseDirectory = "/var/lib/GeoIP";
      };
    };

    services.dovecot2 = lib.mkIf cfg.provision.localMail.enable {
      enable = true;
      protocols = [ "imap" ];
    };

    services.postfix = lib.mkIf cfg.provision.localMail.enable {
      enable = true;
      origin = cfg.provision.localMail.hostname;
      config = {
        myhostname = cfg.provision.localMail.hostname;
        mydestination = cfg.provision.localMail.hostname;
      };
    };

    services.grafana = {
      declarativePlugins = with pkgs.grafanaPlugins;
        lib.mkIf cfg.provision.grafana.dashboard [
          grafana-worldmap-panel
          grafana-piechart-panel
        ];

      provision = {
        enable = cfg.provision.grafana.datasource || cfg.provision.grafana.dashboard;
        datasources =
          let
            esVersion = lib.getVersion config.services.elasticsearch.package;
          in
            lib.mkIf cfg.provision.grafana.datasource [
              {
                name = "dmarc-ag";
                type = "elasticsearch";
                access = "proxy";
                url = "http://localhost:9200";
                jsonData = {
                  timeField = "date_range";
                  inherit esVersion;
                };
              }
              {
                name = "dmarc-fo";
                type = "elasticsearch";
                access = "proxy";
                url = "http://localhost:9200";
                jsonData = {
                  timeField = "date_range";
                  inherit esVersion;
                };
              }
            ];
        dashboards = lib.mkIf cfg.provision.grafana.dashboard [{
          name = "parsedmarc";
          options.path = "${pkgs.python3Packages.parsedmarc.dashboard}";
        }];
      };
    };

    services.parsedmarc.settings = lib.mkMerge [
      (lib.mkIf cfg.provision.elasticsearch {
        elasticsearch = {
          hosts = [ "localhost:9200" ];
          ssl = false;
        };
      })
      (lib.mkIf cfg.provision.localMail.enable {
        imap = {
          host = "localhost";
          port = 143;
          ssl = false;
          user = cfg.provision.localMail.recipientName;
          password = "${pkgs.writeText "imap-password" "@imap-password@"}";
          watch = true;
        };
      })
    ];

    systemd.services.parsedmarc =
      let
        # Remove any empty attributes from the config, i.e. empty
        # lists, empty attrsets and null. This makes it possible to
        # list interesting options in `settings` without them always
        # ending up in the resulting config.
        filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [ null [] {} ])) cfg.settings;

        # Extract secrets (attributes set to an attrset with a
        # "_secret" key) from the settings and generate the commands
        # to run to perform the secret replacements.
        secretPaths = lib.catAttrs "_secret" (lib.collect isSecret filteredConfig);
        parsedmarcConfig = ini.generate "parsedmarc.ini" filteredConfig;
        mkSecretReplacement = file: ''
          replace-secret ${lib.escapeShellArgs [ (hashString "sha256" file) file "/run/parsedmarc/parsedmarc.ini" ]}
        '';
        secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
      in
        {
          wantedBy = [ "multi-user.target" ];
          after = [ "postfix.service" "dovecot2.service" "elasticsearch.service" ];
          path = with pkgs; [ replace-secret openssl shadow ];
          serviceConfig = {
            ExecStartPre = let
              startPreFullPrivileges = ''
                set -o errexit -o pipefail -o nounset -o errtrace
                shopt -s inherit_errexit

                umask u=rwx,g=,o=
                cp ${parsedmarcConfig} /run/parsedmarc/parsedmarc.ini
                chown parsedmarc:parsedmarc /run/parsedmarc/parsedmarc.ini
                ${secretReplacements}
              '' + lib.optionalString cfg.provision.localMail.enable ''
                openssl rand -hex 64 >/run/parsedmarc/dmarc_user_passwd
                replace-secret '@imap-password@' '/run/parsedmarc/dmarc_user_passwd' /run/parsedmarc/parsedmarc.ini
                echo "Setting new randomized password for user '${cfg.provision.localMail.recipientName}'."
                cat <(echo -n "${cfg.provision.localMail.recipientName}:") /run/parsedmarc/dmarc_user_passwd | chpasswd
              '';
            in
              "+${pkgs.writeShellScript "parsedmarc-start-pre-full-privileges" startPreFullPrivileges}";
            Type = "simple";
            User = "parsedmarc";
            Group = "parsedmarc";
            DynamicUser = true;
            RuntimeDirectory = "parsedmarc";
            RuntimeDirectoryMode = 0700;
            CapabilityBoundingSet = "";
            PrivateDevices = true;
            PrivateMounts = true;
            PrivateUsers = true;
            ProtectClock = true;
            ProtectControlGroups = true;
            ProtectHome = true;
            ProtectHostname = true;
            ProtectKernelLogs = true;
            ProtectKernelModules = true;
            ProtectKernelTunables = true;
            ProtectProc = "invisible";
            ProcSubset = "pid";
            SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
            RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
            RestrictRealtime = true;
            RestrictNamespaces = true;
            MemoryDenyWriteExecute = true;
            LockPersonality = true;
            SystemCallArchitectures = "native";
            ExecStart = "${pkgs.python3Packages.parsedmarc}/bin/parsedmarc -c /run/parsedmarc/parsedmarc.ini";
          };
        };

    users.users.${cfg.provision.localMail.recipientName} = lib.mkIf cfg.provision.localMail.enable {
      isNormalUser = true;
      description = "DMARC mail recipient";
    };
  };

  # Don't edit the docbook xml directly, edit the md and generate it:
  # `pandoc parsedmarc.md -t docbook --top-level-division=chapter --extract-media=media -f markdown+smart > parsedmarc.xml`
  meta.doc = ./parsedmarc.xml;
  meta.maintainers = [ lib.maintainers.talyz ];
}
